ruvio-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. ruvio_client-0.1.0/LICENSE +21 -0
  2. ruvio_client-0.1.0/PKG-INFO +368 -0
  3. ruvio_client-0.1.0/README.md +333 -0
  4. ruvio_client-0.1.0/pyproject.toml +51 -0
  5. ruvio_client-0.1.0/ruvio/__init__.py +73 -0
  6. ruvio_client-0.1.0/ruvio/_cluster.py +409 -0
  7. ruvio_client-0.1.0/ruvio/_commands.py +1749 -0
  8. ruvio_client-0.1.0/ruvio/_converters.py +274 -0
  9. ruvio_client-0.1.0/ruvio/_models.py +86 -0
  10. ruvio_client-0.1.0/ruvio/_pipeline.py +82 -0
  11. ruvio_client-0.1.0/ruvio/_resp.py +443 -0
  12. ruvio_client-0.1.0/ruvio/asyncio/__init__.py +7 -0
  13. ruvio_client-0.1.0/ruvio/asyncio/client.py +672 -0
  14. ruvio_client-0.1.0/ruvio/asyncio/cluster.py +594 -0
  15. ruvio_client-0.1.0/ruvio/client.py +641 -0
  16. ruvio_client-0.1.0/ruvio/cluster.py +497 -0
  17. ruvio_client-0.1.0/ruvio/errors.py +113 -0
  18. ruvio_client-0.1.0/ruvio/py.typed +0 -0
  19. ruvio_client-0.1.0/ruvio_client.egg-info/PKG-INFO +368 -0
  20. ruvio_client-0.1.0/ruvio_client.egg-info/SOURCES.txt +33 -0
  21. ruvio_client-0.1.0/ruvio_client.egg-info/dependency_links.txt +1 -0
  22. ruvio_client-0.1.0/ruvio_client.egg-info/requires.txt +7 -0
  23. ruvio_client-0.1.0/ruvio_client.egg-info/top_level.txt +1 -0
  24. ruvio_client-0.1.0/setup.cfg +4 -0
  25. ruvio_client-0.1.0/tests/test_client_edge.py +1365 -0
  26. ruvio_client-0.1.0/tests/test_cluster_edge.py +925 -0
  27. ruvio_client-0.1.0/tests/test_cluster_live.py +540 -0
  28. ruvio_client-0.1.0/tests/test_cluster_pure.py +247 -0
  29. ruvio_client-0.1.0/tests/test_cluster_unit.py +490 -0
  30. ruvio_client-0.1.0/tests/test_command_encoding.py +249 -0
  31. ruvio_client-0.1.0/tests/test_coverage.py +175 -0
  32. ruvio_client-0.1.0/tests/test_helpers_live.py +443 -0
  33. ruvio_client-0.1.0/tests/test_live.py +842 -0
  34. ruvio_client-0.1.0/tests/test_resp.py +161 -0
  35. ruvio_client-0.1.0/tests/test_resp_edge.py +434 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Salih Cantekin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,368 @@
1
+ Metadata-Version: 2.4
2
+ Name: ruvio-client
3
+ Version: 0.1.0
4
+ Summary: Fast sync and asyncio Python client for the Ruvio key-value server (RESP2/RESP3)
5
+ Author: Salih Cantekin
6
+ License: MIT
7
+ Project-URL: Homepage, https://salihcantekin.github.io/ruvio/client-python.html
8
+ Project-URL: Documentation, https://salihcantekin.github.io/ruvio/client-python.html
9
+ Project-URL: Repository, https://github.com/salihcantekin/ruvio
10
+ Project-URL: Issues, https://github.com/salihcantekin/ruvio/issues
11
+ Keywords: ruvio,redis,resp,client,cache,asyncio,cluster
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Database
24
+ Classifier: Framework :: AsyncIO
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.9
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Provides-Extra: hiredis
30
+ Requires-Dist: hiredis>=2.0; extra == "hiredis"
31
+ Provides-Extra: test
32
+ Requires-Dist: pytest>=7; extra == "test"
33
+ Requires-Dist: pytest-asyncio>=0.23; extra == "test"
34
+ Dynamic: license-file
35
+
36
+ # ruvio-client (Python)
37
+
38
+ Python client for [Ruvio](https://salihcantekin.github.io/ruvio/). It has no runtime
39
+ dependencies and needs Python 3.9 or newer. It ships type information (`py.typed`).
40
+
41
+ - `ruvio.Ruvio` is the synchronous client. It uses one socket and a reusable `recv_into` buffer.
42
+ - `ruvio.asyncio.Ruvio` is the asyncio client. It keeps one multiplexed, pipelined connection
43
+ that all coroutines share.
44
+ - There is a typed method for each of the **287** commands in `docs/compatibility-matrix.json`.
45
+ The methods also cover the options that change Ruvio's behaviour: `SET … IFEQ`,
46
+ `INCRBY … MAX`, `FILLGET … WAIT`, `FILLSET … SWR`, `GETEX` in milliseconds,
47
+ `DELEX key IFEQ v`, `LPUSHCAP`/`RPUSHCAP`, `BF.*`, `JSON.*` (including `JSON.INDEX` and
48
+ `JSON.FIND`), `IDEM BEGIN/COMPLETE`, `LEASE`/`RELEASE`, `SEMAPHORE`, `ONCE`, `TAKE`, `SETV`,
49
+ `CHANGES`, `LIMIT` and `XADD … DELAY`.
50
+ - Pipelines, MULTI/EXEC transactions, Pub/Sub on a dedicated connection, and RESP3 through
51
+ `HELLO 3`, including push messages.
52
+ - Helpers: lock, lease and semaphore scopes, rate limiter, `get_or_create`, idempotency,
53
+ scan iterators, typed JSON, "recent items", and Bloom "seen before".
54
+
55
+ Licensed under the MIT license. See `LICENSE`.
56
+
57
+ ## Install
58
+
59
+ ```bash
60
+ pip install ./clients/python # ruvio-client, import name: ruvio
61
+ pip install "./clients/python[hiredis]" # optional: hiredis speeds up reply parsing
62
+ ```
63
+
64
+ ## Quick start
65
+
66
+ ```python
67
+ import ruvio
68
+
69
+ r = ruvio.Ruvio("127.0.0.1", 6379, db=0) # or ruvio.Ruvio.from_url("ruvio://host:6379/0")
70
+ r.set("greeting", "hello", px=60_000) # True
71
+ r.get("greeting") # 'hello' (None when missing)
72
+ r.set("greeting", "hi", ifeq="hello") # Ruvio: write only if the value matches
73
+ r.incrby("stock", 5, max=100) # Ruvio: None (nothing written) past the limit
74
+ r.delex("greeting", "hi") # Ruvio: delete only if the value matches
75
+ r.raw.get("greeting") # bytes, on the same connection
76
+ ```
77
+
78
+ ```python
79
+ import asyncio
80
+ from ruvio.asyncio import Ruvio
81
+
82
+ async def main():
83
+ async with Ruvio("127.0.0.1", 6379) as r: # connects lazily if you skip `async with`
84
+ await r.set("k", "v")
85
+ values = await asyncio.gather(*(r.get("k") for _ in range(1000))) # one socket
86
+
87
+ asyncio.run(main())
88
+ ```
89
+
90
+ Replies are converted to Python values. A bulk string becomes `str`, or `bytes` on
91
+ `client.raw` and with `decode_responses=False`. Integers become `int` and scores become
92
+ `float`. Nil becomes `None`. `OK` becomes `True` for commands whose only answer is OK.
93
+ `HGETALL` returns a `dict`, `SMEMBERS` a `set`, and `ZRANGE … withscores=True` a list of
94
+ `(member, score)`. Streams use `StreamEntry(id, fields)`, `FILLGET` uses
95
+ `FillResult(state, value, token)` and `IDEM BEGIN` uses `IdemResult(state, result)`.
96
+
97
+ A string that is not valid UTF-8 comes back as a `str` with surrogate escapes. It
98
+ round-trips: send it back and the server stores the original bytes. Use `client.raw` when
99
+ you want `bytes`.
100
+
101
+ Server errors raise `ruvio.ResponseError` or a more specific class: `WrongTypeError`,
102
+ `NoScriptError`, `NoGroupError`, `BusyError`, `ExecAbortError` or `MovedError`. Each one has
103
+ a `.code` such as `"WRONGTYPE"`. Socket problems raise `ruvio.ConnectionError` or
104
+ `ruvio.TimeoutError`.
105
+
106
+ For any other command, `execute_command("CMD", arg, …)` sends it as written and returns the
107
+ raw reply.
108
+
109
+ ### Pipelines and transactions
110
+
111
+ ```python
112
+ p = r.pipeline() # async: await p.execute()
113
+ p.set("a", 1).incr("a").get("a")
114
+ p.execute() # [True, 2, '2']
115
+ t = r.pipeline(transaction=True) # MULTI … EXEC, one write
116
+ t.set("x", "1").append("x", "2")
117
+ t.execute() # [True, 2]
118
+ r.transaction(lambda pipe: pipe.set("bal", int(r.get("bal")) - 1), "bal") # WATCH + retry
119
+ ```
120
+
121
+ By default, `execute(raise_on_error=True)` raises the first error. Pass
122
+ `raise_on_error=False` to get the exceptions back in their places in the result list.
123
+ Ruvio rejects an unknown command while it is being queued and still runs the rest of the
124
+ block. The client keeps the rejection at that command's index.
125
+
126
+ On the async client, `transaction()` opens a dedicated connection, because WATCH applies
127
+ to the whole connection.
128
+
129
+ ### Pub/Sub (on its own socket)
130
+
131
+ ```python
132
+ ps = r.pubsub(ignore_subscribe_messages=True) # async: ps = await r.pubsub()
133
+ ps.subscribe("news"); ps.psubscribe("ev.*"); ps.ssubscribe("shard-chan")
134
+ for msg in ps.listen(): # async: async for msg in ps.listen()
135
+ print(msg.kind, msg.channel, msg.data, msg.pattern)
136
+ ```
137
+
138
+ When the socket drops, the next `get_message` reopens it and sends the subscriptions again.
139
+
140
+ ### RESP3
141
+
142
+ `r.hello(3)` switches the connection to RESP3. The typed converters accept RESP3 maps,
143
+ doubles and nested pairs. Out-of-band pushes, such as `CLIENT TRACKING` invalidations, go
144
+ to `on_push=callback` and never reach a command's reply.
145
+
146
+ ## Helpers
147
+
148
+ | Helper | Built on | Notes |
149
+ | --- | --- | --- |
150
+ | `r.lock(name, ttl_ms)` (`with` / `async with`) | `SET NX PX`; renew `SET token IFEQ token PX`; release `DELEX name IFEQ token` | Never deletes another holder's lock. `acquire(blocking, timeout)`, `renew()`, `release()`, `owned()` |
151
+ | `r.lease_scope(key, ttl_ms)` | `LEASE` / `RELEASE` | Yields the fencing token and releases it on exit |
152
+ | `r.semaphore_scope(key, max, ttl_ms)` | `SEMAPHORE` / `RELEASE` | Raises `LockError` when full |
153
+ | `r.rate_limit(key, max, window_ms)` | `LIMIT` | `(allowed, remaining)` |
154
+ | `r.get_or_create(key, factory, ttl_ms, fill_ms=…, swr_ms=…)` | `FILLGET` / `FILLSET` | Exactly one caller runs `factory` (sync or async). Waiters poll plain `FILLGET` with backoff from 5 ms up to 100 ms instead of `WAIT`, which would park the shared connection |
155
+ | `r.idempotent(key, fingerprint, ttl_ms, fn)` | `IDEM BEGIN` / `COMPLETE` | `("executed", result)`, `("completed", stored)`, `("pending", None)` or `("conflict", None)` |
156
+ | `r.scan_iter()` / `hscan_iter` / `sscan_iter` / `zscan_iter` | `SCAN` family | Lazy and driven by the cursor; async iterators on the asyncio client |
157
+ | `r.json_set_obj` / `json_get_obj` / `json_mget_obj` | `JSON.SET` / `JSON.GET` / `JSON.MGET` | Uses the standard `json` module |
158
+ | `r.push_recent(key, value, keep)` | `LPUSHCAP` | Newest first, keeps at most `keep` items |
159
+ | `r.seen_before(key, *items)` | `BF.MADD` | Returns one `bool` per item: was it (probably) seen before this call |
160
+
161
+ ## Performance design
162
+
163
+ The design follows the C# client:
164
+
165
+ 1. **One shared connection.** The asyncio client appends `(future, converter, decode)` to a
166
+ FIFO deque, so concurrent coroutines share one socket.
167
+ 2. **Write coalescing without timers.** On an idle connection a frame is written at once.
168
+ While replies are still outstanding, frames from the same event-loop iteration are joined
169
+ into one `transport.write`. A `call_soon` flush sends them, with a cap of 64 KiB.
170
+ Profiled at 64 concurrent coroutines, one write carried about 28 commands and one read
171
+ about 44 replies.
172
+ 3. **Pre-encoded command fragments.** `$3\r\nGET\r\n` is built once per command name. The
173
+ bulk-length headers `$N\r\n` come from a 4096-entry table. Integers 0–65535 come from a
174
+ table of complete bulk fragments, so they need no int→str conversion. `GET`, `SET`,
175
+ `INCR`, `HGET` and `DEL` build their frame with a single `b"".join`.
176
+ 4. **Reading in place.** The asyncio reader is an `asyncio.BufferedProtocol`, so the event
177
+ loop reads straight into the parser's reusable `bytearray`; there is no StreamReader and
178
+ no `bytes` object per read. The sync client uses `recv_into` on the same buffer.
179
+ 5. **Parser fast paths.** The parser finds CRLF with `bytes.find` and decodes small values
180
+ from `bytearray` slices, which are faster than `memoryview` slices on CPython. Large
181
+ values go through the `memoryview` to avoid a second copy. A one- or two-digit bulk
182
+ length is read without `find`/`int`. A flat array of bulk strings and integers is read
183
+ in one tight loop. The parser can resume: an array that arrives in many chunks keeps
184
+ the items already parsed and is never parsed again from the start. A scalar reply
185
+ allocates only the value it returns.
186
+ 6. **Optional `hiredis`.** If `hiredis` can be imported, `parser="auto"` (the default) uses
187
+ it. Otherwise the client uses the pure-Python parser. You can force either with
188
+ `parser="python"` or `parser="hiredis"`.
189
+
190
+ ### Measured
191
+
192
+ These numbers come from `benchmarks/bench.py` with Python 3.13.16 on a shared, noisy
193
+ 4-vCPU Linux VM. The server was a local Ruvio over loopback, with 64-byte values. Each
194
+ figure is a single run of 2 seconds. They are indicative, not release numbers.
195
+
196
+ | | ruvio-client | redis-py 8.1.0 (hiredis) |
197
+ | --- | ---: | ---: |
198
+ | sync serial GET / SET ops/s | 17,500 / 18,850 | 13,150 / 14,700 |
199
+ | sync pipeline GET (100 per batch) ops/s | 605,600 | not measured |
200
+ | asyncio, 1 coroutine, GET / SET ops/s | 29,379 / 28,447 | 7,431 / 6,968 |
201
+ | asyncio, 64 coroutines, GET / SET ops/s | 186,018 / 147,844 (1 connection) | 8,250 / 8,150 (pool of up to 64) |
202
+
203
+ Per-reply parse cost, including copying the reply into the read buffer:
204
+
205
+ | reply | pure Python | hiredis |
206
+ | --- | ---: | ---: |
207
+ | `+OK` | ~870 ns | ~610 ns |
208
+ | `:int` | ~900 ns | ~760 ns |
209
+ | `$bulk64` | ~1030 ns | ~660 ns |
210
+ | `*10` bulk | ~5100 ns | ~1900 ns |
211
+
212
+ Encoding a GET takes about 425 ns through the generic encoder and about 310 ns through the
213
+ typed method. Encoding a 64-byte SET takes about 580 ns through the typed method. Each
214
+ GET frame allocates 70 bytes. A parsed `$bulk64` reply keeps only the returned `str`
215
+ (about 114 bytes).
216
+
217
+ ```bash
218
+ RUVIO_TEST_ADDR=127.0.0.1:6379 python benchmarks/bench.py --seconds 1 --redis-py
219
+ ```
220
+
221
+ ## Cluster
222
+
223
+ ```python
224
+ c = ruvio.Ruvio("127.0.0.1", 17330, cluster=True) # sync
225
+ c = ruvio.asyncio.Ruvio("127.0.0.1", 17330, cluster=True) # asyncio
226
+ ```
227
+
228
+ `cluster=True` is opt-in; the plain `Ruvio(...)` class (and its fast path) is not touched.
229
+ With it, the first command (or `connect()`) asks the seed for `CLUSTER SLOTS` and builds a
230
+ 16384-entry slot -> node table. One socket per node is opened lazily (asyncio: one multiplexed
231
+ connection per node, the same pipelined protocol as the standalone client). A server that
232
+ answers `-ERR cluster mode is disabled` (or lists a single node) is detected there and used as a
233
+ plain standalone server from then on (`client.is_cluster` / `await client.is_cluster()` tells).
234
+ The slot is `CRC16-XMODEM(key or {hashtag}) mod 16384` (computed with `binascii.crc_hqx`, checked
235
+ against `CLUSTER KEYSLOT`); `ruvio.key_slot(key)` exposes it.
236
+
237
+ | Command kind | Routing |
238
+ |---|---|
239
+ | Single key (argument 1: `GET`, `SET`, `HSET`, `XADD`, `BF.*`, `JSON.*`, Ruvio primitives, `SPUBLISH` ...) | the node that owns the key's slot |
240
+ | Keyless (`PING`, `INFO`, `CONFIG`, `ACL`, `CLIENT ...`, `CLUSTER ...`, `SLOWLOG`, `JSON.INDEX/FIND` ...) | the seed node |
241
+ | `MGET`, `MSET`, `DEL`, `UNLINK`, `EXISTS`, `TOUCH` across slots | one sub-command per slot, sent to all owning nodes before any reply is read, replies re-assembled in the caller's order (MGET positions, summed counts, `OK`) |
242
+ | Other multi-key commands (`RENAME`, `COPY`, `SMOVE`, `LMOVE`, `SINTER*`, `SUNION*`, `SDIFF*`, `ZUNION/ZINTER/ZDIFF[STORE]`, `BITOP`, `PFCOUNT/PFMERGE`, `SINTERCARD`, `LMPOP/BLMPOP`, `BLPOP/BRPOP/BZPOP*`, `EVAL/EVALSHA/FCALL`, `XREAD[GROUP]`, `GEOSEARCHSTORE`, `GEORADIUS ... STORE`, `JSON.MGET`, `MSETNX`, `WATCH`) | routed by their real key positions; keys in different slots raise `CrossSlotError` **before anything is sent** |
243
+ | `DBSIZE` | summed over all nodes |
244
+ | `FLUSHDB`, `FLUSHALL`, `SCRIPT LOAD/FLUSH`, `FUNCTION LOAD/DELETE/FLUSH`, `AUTH`, `SELECT`, `CLIENT SETNAME` | run on every node, one reply returned |
245
+ | `KEYS` | every node, results concatenated |
246
+ | `SCAN` | walks the nodes one after the other: the cursor you get back is `server_cursor * nodes + node`, `0` only after the last node (treat it as opaque). `scan_iter()` does this for you |
247
+ | `PUBLISH`, classic `SUBSCRIBE`/`PSUBSCRIBE` | the owner of slot 0, so publishers and subscribers meet; `ssubscribe` connects to the owner of the channel's slot |
248
+ | `MULTI`/`EXEC`/`DISCARD` sent one by one | `RuvioError` (the queued commands may belong to different nodes); use `pipeline(transaction=True)` or `transaction(fn, *watch)` |
249
+
250
+ Behaviour worth knowing:
251
+
252
+ - **`-MOVED`**: the first one patches that slot in the table (node already known); a second one
253
+ since the last refresh, or a node that is not in the table, re-reads `CLUSTER SLOTS`. The
254
+ command is then retried **once** on the node the reply names (a second `MOVED` raises
255
+ `MovedError`). `MOVED` means the command was not executed, so the retry is safe.
256
+ - **No replay after transport failures.** A dropped node socket fails the command that saw it
257
+ (`ConnectionError`), is closed, and is reopened by the next command for that node. Other nodes
258
+ are not affected. A pipeline/split call that touched several nodes still reads every healthy
259
+ node's replies before raising.
260
+ - **Pipelines** group commands by node, send each group as one pipelined write (all groups are
261
+ written before any is read; asyncio gathers them) and return replies in the caller's order.
262
+ A cross-slot multi-key command, or a fan-out command (`DBSIZE`, `KEYS`, `SCAN`...), inside a
263
+ pipeline raises before sending. A command in a pipeline that gets `MOVED` is retried singly
264
+ after the batch, so its order relative to the other commands is not preserved for that retry.
265
+ - **Transactions** (`pipeline(transaction=True)`, `transaction()`) need all keys in one slot, else
266
+ `CrossSlotError`. If `WATCH` ran on another node than the transaction's keys the call also
267
+ raises (the watch would not apply). A `MOVED` transaction is re-sent once, whole, to the new owner.
268
+ - **Blocking commands** (asyncio): `BLPOP`, `BRPOP`, `BLMOVE`, `BLMPOP`, `BZPOP*`, `XREAD ... BLOCK`
269
+ and `FILLGET ... WAIT` run on a short-lived dedicated connection to the owning node, so they
270
+ never stall the shared one. The sync client simply blocks on that node's socket.
271
+ - **Helpers** (lock, lease/semaphore scopes, rate limit, `get_or_create`, idempotency, push_recent,
272
+ seen_before, JSON helpers, scan iterators) are unchanged: they only issue single-key commands.
273
+ `transaction()` and `dedicated()` return cluster clients.
274
+ - Cluster mode has only database 0 (so `db=` other than 0 is a server error on connect).
275
+
276
+ Cost: the router reads the command name and first key out of the already-encoded frame
277
+ (`frame_head` + `crc_hqx`, about 0.65 us per command in CPython 3.13, from `benchmarks/bench.py`), then
278
+ indexes two lists. Only `ClusterRuvio` pays it.
279
+
280
+ Short benchmark (`RUVIO_TEST_ADDR` plain 1-shard server vs `RUVIO_CLUSTER_ADDR` 3-shard server, same machine with 4 cores, 64 spread keys, 3 s per row, one run, noisy; the serial rows are dominated by server wake-up latency when alternating between shards): sync serial GET 26.6k vs 22.1k ops/s, sync pipeline of 64 GET 526k vs 322k ops/s, asyncio 64 concurrent GET 239k vs 129k ops/s and SET 192k vs 125k ops/s (standalone vs cluster). The cluster client does roughly half the standalone throughput in the CPU-bound asyncio case: routing work in Python plus smaller per-connection write batches over three connections. asyncio keeps the warm path free of an extra
281
+ coroutine: the node connection's pending-reply future is returned directly, and only a `MOVED`
282
+ reply is intercepted.
283
+
284
+ ## Tests
285
+
286
+ ```bash
287
+ python -m venv .venv && .venv/bin/pip install pytest pytest-asyncio # optional: hiredis redis
288
+ RUVIO_TEST_ADDR=127.0.0.1:6379 .venv/bin/python -m pytest # live + unit tests
289
+ RUVIO_TEST_PARSER=python ... # force the pure-Python parser
290
+ RUVIO_TEST_FLUSHALL=1 ... # also run the FLUSHALL test
291
+ ```
292
+
293
+ - `tests/test_resp.py` contains the codec unit tests. They need no server: encoder, both
294
+ parsers, byte-by-byte feeding, RESP3 types, buffer growth, resumable arrays.
295
+ - `tests/test_live.py` holds one scenario per command family. Each scenario runs on
296
+ **both** the sync and the asyncio client.
297
+ - `tests/test_helpers_live.py` covers the helpers, Pub/Sub, RESP3 pushes, multiplexing
298
+ order, cancellation, reconnect, timeouts, large values, `WATCH`, and URLs.
299
+ - `tests/test_coverage.py` reads `docs/compatibility-matrix.json`. It fails when a command
300
+ has no typed method on the sync or the async client. It also fails when a typed method is
301
+ never called by a live test.
302
+ - `tests/test_cluster_unit.py` needs no Ruvio server: slot vectors and hashtags, key
303
+ extraction through the typed methods, a check that **all 287 matrix commands have a routing
304
+ rule**, topology / `MOVED` / scan-cursor parsing, and the router against scripted fake servers
305
+ (`tests/fakecluster.py`) on both clients: owner-only routing, fail-fast `CROSSSLOT` (no byte
306
+ sent), split reassembly order, `MOVED` patch / refresh / unknown node / retry-once, no replay
307
+ after a transport failure, pipeline grouping and order, transactions on one node, standalone
308
+ fallback.
309
+ - `tests/test_cluster_live.py` runs against a 3-shard server with
310
+ `RUVIO_CLUSTER_ADDR=127.0.0.1:17330` (shard i on base+i; start it with
311
+ `shards = 3` and `port_mode = "per-shard"`) on both clients: slot table vs `CLUSTER KEYSLOT`,
312
+ keys spread over all shards, cross-slot `MGET/MSET/DEL/EXISTS/TOUCH/UNLINK` order,
313
+ cross-slot errors leave data untouched, same-slot multi-key commands, script fan-out,
314
+ pipeline order, tx single-slot / cross-slot, `WATCH` + retry, `MOVED` recovery from a fully
315
+ stale table (command, pipeline, split, transaction), helpers, scan iterators over all nodes,
316
+ `BLPOP`, pub/sub and sharded pub/sub, losing one node's connection. The standalone fallback
317
+ test uses `RUVIO_TEST_ADDR` (plain server). Cluster mode has only database 0, so the older
318
+ live suites (database 11) are not run on the cluster.
319
+ - Without `RUVIO_TEST_ADDR` the live tests are skipped. Live tests use database 11 and run
320
+ `FLUSHDB`. `test_keys` also uses database 12. `JSON.INDEX` only works in database 0, so
321
+ that test uses unique keys there and cleans up after itself.
322
+ - `ACL SAVE`/`ACL LOAD` are only exercised end to end when the server has
323
+ `security.acl_file`. Without it, the test only checks that the server's error is raised.
324
+
325
+ ## Not done / not verified
326
+
327
+ - **Cluster scope.** Routing is done and live-tested on a 3-shard server (see Cluster), but:
328
+ `MULTI`/`EXEC` sent one by one are refused (use `pipeline(transaction=True)`); a
329
+ cross-slot `RENAME`/`COPY`/`SMOVE`/`LMOVE`/... is refused rather than emulated by
330
+ copying; `CLUSTER NODES/SHARDS/INFO/MYID` and `CHANGES` go to the seed only; `SCRIPT EXISTS`
331
+ and `SCRIPT KILL` ask the seed only; there is no `ASK` handling (the server does not
332
+ migrate slots); no replica reads (`READONLY` is sent to the seed only); a cluster whose
333
+ advertised host is unreachable from the client is not handled. Not verified: a server
334
+ topology that changes while the client runs (`MOVED` recovery was verified by corrupting the
335
+ client's table, not by moving slots on a server), clusters with more than 3 nodes, AUTH on
336
+ a cluster with a password, a pipeline > 64 KiB per node with a dead peer.
337
+ - **Typing.** The command methods are shared by the sync client, the async client and
338
+ pipelines, so their return annotations are `Any`. Each docstring states the shape of the
339
+ value.
340
+ - **Threads.** The sync client is not thread-safe and has no connection pool. Use one
341
+ client per thread, or `dedicated()`.
342
+ - **Blocking commands on the shared asyncio connection.** `BLPOP`, `XREAD BLOCK`,
343
+ `FILLGET … WAIT` and similar hold the connection for every coroutine. Run them on
344
+ `client.dedicated()` (the cluster client does this for you). Cancelling an awaiting caller is safe: the late reply is dropped.
345
+ - **No retries.** No command is sent again after a connection loss. The command that sees
346
+ the drop raises `ConnectionError`, and the next command reconnects and re-applies `AUTH`,
347
+ `SELECT` and `CLIENT SETNAME`.
348
+ - **No write backpressure in asyncio.** The client does not handle `pause_writing`.
349
+ Writes are capped at 64 KiB each, but the transport's buffer can grow without limit if
350
+ the server stops reading.
351
+ - **Inside a pipeline, `dump()`** follows the client's `decode_responses` setting. Use
352
+ `client.raw.pipeline()` to get `bytes`.
353
+ - **With hiredis**, RESP3 big numbers (`(`) are returned as `str`; the pure-Python parser
354
+ returns `int`.
355
+
356
+ Server behaviours observed while testing this Ruvio build. Each is covered by a test and
357
+ documented here because it differs from Redis or from the matrix text:
358
+
359
+ - `ZRANGE key max min BYSCORE REV` takes the high bound first, as Redis does (older server builds expected min first).
360
+ - `XADD … MAXLEN = n` is rejected, while `XTRIM … MAXLEN = n` is accepted. The client never
361
+ sends `=`, because an exact trim is the default.
362
+ - `EXPIRE key 0` deletes the key and returns 1, as Redis does (the matrix text was corrected).
363
+ - `FLUSHDB` and `FLUSHALL` clear in-flight `FILLGET` fill markers (older builds kept them until `fill_ms`).
364
+ - In Lua scripts the API object is `ruvio.call(...)`. `redis.call` fails with
365
+ `Error running script`.
366
+ - An unknown command inside `MULTI` is rejected when queued and `EXEC` answers `EXECABORT`
367
+ (`ruvio.ExecAbortError`) and runs nothing, as Redis does. Older server builds ran the rest.
368
+ - `AUTH <anything>` succeeds when no `requirepass` is configured.