ruvio-client 0.1.0__py3-none-any.whl
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.
- ruvio/__init__.py +73 -0
- ruvio/_cluster.py +409 -0
- ruvio/_commands.py +1749 -0
- ruvio/_converters.py +274 -0
- ruvio/_models.py +86 -0
- ruvio/_pipeline.py +82 -0
- ruvio/_resp.py +443 -0
- ruvio/asyncio/__init__.py +7 -0
- ruvio/asyncio/client.py +672 -0
- ruvio/asyncio/cluster.py +594 -0
- ruvio/client.py +641 -0
- ruvio/cluster.py +497 -0
- ruvio/errors.py +113 -0
- ruvio/py.typed +0 -0
- ruvio_client-0.1.0.dist-info/METADATA +368 -0
- ruvio_client-0.1.0.dist-info/RECORD +19 -0
- ruvio_client-0.1.0.dist-info/WHEEL +5 -0
- ruvio_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- ruvio_client-0.1.0.dist-info/top_level.txt +1 -0
ruvio/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# MIT License - Copyright (c) 2026 Salih Cantekin. See LICENSE.
|
|
2
|
+
"""Python client for Ruvio.
|
|
3
|
+
|
|
4
|
+
``ruvio.Ruvio`` is the synchronous client; ``ruvio.asyncio.Ruvio`` is the asyncio client
|
|
5
|
+
(one pipelined, multiplexed connection shared by every coroutine).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from ._commands import parse_info
|
|
9
|
+
from ._models import (
|
|
10
|
+
AutoClaimResult,
|
|
11
|
+
FillResult,
|
|
12
|
+
GeoResult,
|
|
13
|
+
IdemResult,
|
|
14
|
+
Message,
|
|
15
|
+
PendingEntry,
|
|
16
|
+
PendingSummary,
|
|
17
|
+
StreamEntry,
|
|
18
|
+
)
|
|
19
|
+
from ._cluster import key_slot
|
|
20
|
+
from .client import Lock, Pipeline, PubSub, Ruvio, parse_url
|
|
21
|
+
from .cluster import ClusterRuvio
|
|
22
|
+
from .errors import (
|
|
23
|
+
BusyError,
|
|
24
|
+
ConnectionError,
|
|
25
|
+
CrossSlotError,
|
|
26
|
+
ExecAbortError,
|
|
27
|
+
LockError,
|
|
28
|
+
MovedError,
|
|
29
|
+
NoGroupError,
|
|
30
|
+
NoScriptError,
|
|
31
|
+
ProtocolError,
|
|
32
|
+
ResponseError,
|
|
33
|
+
RuvioError,
|
|
34
|
+
TimeoutError,
|
|
35
|
+
WatchError,
|
|
36
|
+
WrongTypeError,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"Ruvio",
|
|
43
|
+
"ClusterRuvio",
|
|
44
|
+
"key_slot",
|
|
45
|
+
"Pipeline",
|
|
46
|
+
"PubSub",
|
|
47
|
+
"Lock",
|
|
48
|
+
"parse_url",
|
|
49
|
+
"parse_info",
|
|
50
|
+
"AutoClaimResult",
|
|
51
|
+
"FillResult",
|
|
52
|
+
"GeoResult",
|
|
53
|
+
"IdemResult",
|
|
54
|
+
"Message",
|
|
55
|
+
"PendingEntry",
|
|
56
|
+
"PendingSummary",
|
|
57
|
+
"StreamEntry",
|
|
58
|
+
"BusyError",
|
|
59
|
+
"ConnectionError",
|
|
60
|
+
"CrossSlotError",
|
|
61
|
+
"ExecAbortError",
|
|
62
|
+
"LockError",
|
|
63
|
+
"MovedError",
|
|
64
|
+
"NoGroupError",
|
|
65
|
+
"NoScriptError",
|
|
66
|
+
"ProtocolError",
|
|
67
|
+
"ResponseError",
|
|
68
|
+
"RuvioError",
|
|
69
|
+
"TimeoutError",
|
|
70
|
+
"WatchError",
|
|
71
|
+
"WrongTypeError",
|
|
72
|
+
"__version__",
|
|
73
|
+
]
|
ruvio/_cluster.py
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
# MIT License - Copyright (c) 2026 Salih Cantekin. See LICENSE.
|
|
2
|
+
"""Cluster routing logic shared by the sync and asyncio clients (pure functions, no I/O).
|
|
3
|
+
|
|
4
|
+
Commands arrive at the router as complete RESP frames (``bytes``). Only the command name and
|
|
5
|
+
the first key are read for the common single-key case; every other command is split into
|
|
6
|
+
arguments and run through :func:`plan`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import binascii
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple, Union
|
|
13
|
+
|
|
14
|
+
from ._resp import encode, encode_args
|
|
15
|
+
from .errors import CrossSlotError, ResponseError, RuvioError
|
|
16
|
+
|
|
17
|
+
SLOT_COUNT = 16384
|
|
18
|
+
|
|
19
|
+
_crc16 = binascii.crc_hqx # CRC-CCITT with poly 0x1021 and init 0 == CRC16/XMODEM, in C
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def key_slot(key: Union[str, bytes]) -> int:
|
|
23
|
+
"""Hash slot of ``key``: CRC16-XMODEM of the key, or of its first non-empty ``{hashtag}``, mod 16384."""
|
|
24
|
+
if type(key) is not bytes:
|
|
25
|
+
key = key.encode("utf-8", "surrogateescape") if isinstance(key, str) else bytes(key)
|
|
26
|
+
o = key.find(b"{")
|
|
27
|
+
if o >= 0:
|
|
28
|
+
c = key.find(b"}", o + 1)
|
|
29
|
+
if c > o + 1:
|
|
30
|
+
key = key[o + 1 : c]
|
|
31
|
+
return _crc16(key, 0) & 0x3FFF
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --------------------------------------------------------------------------- frames
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def frame_head(data: bytes) -> Tuple[bytes, Optional[bytes]]:
|
|
38
|
+
"""``(name, first_argument)`` of an encoded command without parsing the rest."""
|
|
39
|
+
i = data.find(b"\r\n")
|
|
40
|
+
argc = int(data[1:i])
|
|
41
|
+
j = data.find(b"\r\n", i + 2)
|
|
42
|
+
n = int(data[i + 3 : j])
|
|
43
|
+
s = j + 2
|
|
44
|
+
name = data[s : s + n]
|
|
45
|
+
if argc < 2:
|
|
46
|
+
return name, None
|
|
47
|
+
p = s + n + 2
|
|
48
|
+
k = data.find(b"\r\n", p)
|
|
49
|
+
m = int(data[p + 1 : k])
|
|
50
|
+
return name, data[k + 2 : k + 2 + m]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def split_frame(data: bytes) -> List[bytes]:
|
|
54
|
+
"""All arguments of an encoded command (the command name is element 0)."""
|
|
55
|
+
i = data.find(b"\r\n")
|
|
56
|
+
pos = i + 2
|
|
57
|
+
out: List[bytes] = []
|
|
58
|
+
find = data.find
|
|
59
|
+
for _ in range(int(data[1:i])):
|
|
60
|
+
j = find(b"\r\n", pos)
|
|
61
|
+
s = j + 2
|
|
62
|
+
e = s + int(data[pos + 1 : j])
|
|
63
|
+
out.append(data[s:e])
|
|
64
|
+
pos = e + 2
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------- key positions
|
|
69
|
+
|
|
70
|
+
# kinds of key layout. A command that is not listed has exactly one key: argument 1.
|
|
71
|
+
_KEYLESS = 1 # no key: any node
|
|
72
|
+
_ALL = 2 # every argument after the name is a key
|
|
73
|
+
_TWO = 3 # arguments 1 and 2 are keys
|
|
74
|
+
_MSET = 4 # key value key value ...
|
|
75
|
+
_LASTISTIMEOUT = 5 # BLPOP k1 k2 ... timeout
|
|
76
|
+
_COUNT1 = 6 # name numkeys key...
|
|
77
|
+
_COUNT2 = 7 # name x numkeys key...
|
|
78
|
+
_STORECOUNT = 8 # name dest numkeys key...
|
|
79
|
+
_STREAMS = 9 # ... STREAMS k1 k2 id1 id2
|
|
80
|
+
_SUB2 = 10 # name subcommand key ...
|
|
81
|
+
_BITOP = 11 # BITOP op dest src...
|
|
82
|
+
_LASTISPATH = 12 # JSON.MGET k1 k2 ... path
|
|
83
|
+
_GEO = 13 # GEORADIUS key ... [STORE key]
|
|
84
|
+
# commands with their own routing
|
|
85
|
+
_FAN_SUM = 20 # DBSIZE: ask every node, add the integers
|
|
86
|
+
_FAN_FIRST = 21 # FLUSHDB, AUTH ...: run on every node, return one reply
|
|
87
|
+
_FAN_CAT = 22 # KEYS: ask every node, concatenate
|
|
88
|
+
_SCAN = 23
|
|
89
|
+
_SLOT0 = 24 # PUBLISH: classic pub/sub meets on the owner of slot 0
|
|
90
|
+
_TXN = 25 # MULTI / EXEC / DISCARD sent bare
|
|
91
|
+
_COND = 26 # CLIENT / SCRIPT / FUNCTION: only some sub-commands run everywhere
|
|
92
|
+
|
|
93
|
+
SPEC: Dict[bytes, int] = {}
|
|
94
|
+
|
|
95
|
+
SPEC.update({n.encode(): _KEYLESS for n in (
|
|
96
|
+
"ACL BGSAVE CHANGES CLUSTER COMMAND CONFIG ECHO HELLO INFO JSON.FIND JSON.INDEX LASTSAVE LATENCY PING "
|
|
97
|
+
"PSUBSCRIBE PUNSUBSCRIBE QUIT RANDOMKEY READONLY READWRITE RESET RUVIO SAVE SLOWLOG SUBSCRIBE SWAPDB TIME "
|
|
98
|
+
"UNSUBSCRIBE WAIT"
|
|
99
|
+
).split()})
|
|
100
|
+
SPEC.update({n.encode(): _ALL for n in (
|
|
101
|
+
"DEL EXISTS MGET PFCOUNT PFMERGE SDIFF SDIFFSTORE SINTER SINTERSTORE SSUBSCRIBE SUNION SUNIONSTORE SUNSUBSCRIBE "
|
|
102
|
+
"TOUCH UNLINK WATCH"
|
|
103
|
+
).split()})
|
|
104
|
+
SPEC.update({n.encode(): _TWO for n in (
|
|
105
|
+
"BLMOVE BRPOPLPUSH COPY LMOVE RENAME RENAMENX RPOPLPUSH SMOVE GEOSEARCHSTORE ZRANGESTORE LCS"
|
|
106
|
+
).split()})
|
|
107
|
+
SPEC.update({b"MSET": _MSET, b"MSETNX": _MSET})
|
|
108
|
+
SPEC.update({n.encode(): _LASTISTIMEOUT for n in "BLPOP BRPOP BZPOPMIN BZPOPMAX".split()})
|
|
109
|
+
SPEC.update({n.encode(): _COUNT1 for n in "ZUNION ZINTER ZDIFF LMPOP SINTERCARD ZINTERCARD ZMPOP".split()})
|
|
110
|
+
SPEC.update({n.encode(): _COUNT2 for n in "BLMPOP BZMPOP EVAL EVALSHA EVAL_RO EVALSHA_RO FCALL FCALL_RO".split()})
|
|
111
|
+
SPEC.update({n.encode(): _STORECOUNT for n in "ZUNIONSTORE ZINTERSTORE ZDIFFSTORE".split()})
|
|
112
|
+
SPEC.update({b"XREAD": _STREAMS, b"XREADGROUP": _STREAMS})
|
|
113
|
+
SPEC.update({n.encode(): _SUB2 for n in "OBJECT MEMORY XGROUP XINFO".split()})
|
|
114
|
+
SPEC[b"BITOP"] = _BITOP
|
|
115
|
+
SPEC[b"JSON.MGET"] = _LASTISPATH
|
|
116
|
+
SPEC.update({b"GEORADIUS": _GEO, b"GEORADIUSBYMEMBER": _GEO})
|
|
117
|
+
SPEC.update({b"DBSIZE": _FAN_SUM, b"FLUSHDB": _FAN_FIRST, b"FLUSHALL": _FAN_FIRST, b"AUTH": _FAN_FIRST,
|
|
118
|
+
b"SELECT": _FAN_FIRST, b"KEYS": _FAN_CAT, b"SCAN": _SCAN, b"PUBLISH": _SLOT0})
|
|
119
|
+
SPEC.update({b"MULTI": _TXN, b"EXEC": _TXN, b"DISCARD": _TXN, b"UNWATCH": _KEYLESS})
|
|
120
|
+
SPEC.update({b"CLIENT": _COND, b"SCRIPT": _COND, b"FUNCTION": _COND})
|
|
121
|
+
|
|
122
|
+
# (command, sub-command) pairs of CLIENT / SCRIPT / FUNCTION that must run on every node
|
|
123
|
+
_FAN_SUB = frozenset(
|
|
124
|
+
(
|
|
125
|
+
(b"CLIENT", b"SETNAME"),
|
|
126
|
+
(b"SCRIPT", b"LOAD"),
|
|
127
|
+
(b"SCRIPT", b"FLUSH"),
|
|
128
|
+
(b"FUNCTION", b"LOAD"),
|
|
129
|
+
(b"FUNCTION", b"DELETE"),
|
|
130
|
+
(b"FUNCTION", b"FLUSH"),
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
#: multi-key commands whose keys may live on several nodes: split per node and re-assembled
|
|
135
|
+
SPLITTABLE = frozenset((b"MGET", b"MSET", b"DEL", b"UNLINK", b"EXISTS", b"TOUCH"))
|
|
136
|
+
|
|
137
|
+
#: commands that park the connection until data arrives
|
|
138
|
+
BLOCKING = frozenset((b"BLPOP", b"BRPOP", b"BRPOPLPUSH", b"BLMOVE", b"BLMPOP", b"BZPOPMIN", b"BZPOPMAX", b"BZMPOP"))
|
|
139
|
+
|
|
140
|
+
PLAN_SLOT = 0 # (PLAN_SLOT, slot or None)
|
|
141
|
+
PLAN_SPLIT = 1 # (PLAN_SPLIT, keys)
|
|
142
|
+
PLAN_FAN = 2 # (PLAN_FAN, SPEC kind)
|
|
143
|
+
PLAN_SCAN = 3
|
|
144
|
+
|
|
145
|
+
FAN_SUM = _FAN_SUM
|
|
146
|
+
FAN_FIRST = _FAN_FIRST
|
|
147
|
+
FAN_CAT = _FAN_CAT
|
|
148
|
+
|
|
149
|
+
_UNSUPPORTED_TXN = (
|
|
150
|
+
"MULTI/EXEC/DISCARD cannot be sent one by one on a cluster client (the queued commands may belong to "
|
|
151
|
+
"different nodes); use pipeline(transaction=True) or transaction(fn, *watch_keys)"
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _counted(args: List[bytes], at: int) -> List[bytes]:
|
|
156
|
+
if at >= len(args):
|
|
157
|
+
return []
|
|
158
|
+
try:
|
|
159
|
+
n = int(args[at])
|
|
160
|
+
except ValueError:
|
|
161
|
+
return []
|
|
162
|
+
return args[at + 1 : at + 1 + n]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def command_keys(name: bytes, args: List[bytes]) -> List[bytes]:
|
|
166
|
+
"""The keys of a command in argument order (empty for keyless commands).
|
|
167
|
+
|
|
168
|
+
``name`` is the upper-case first word, ``args`` the whole argument vector including it."""
|
|
169
|
+
kind = SPEC.get(name)
|
|
170
|
+
n = len(args)
|
|
171
|
+
if kind is None:
|
|
172
|
+
return args[1:2]
|
|
173
|
+
if kind == _SUB2:
|
|
174
|
+
return args[2:3]
|
|
175
|
+
if n < 2:
|
|
176
|
+
return []
|
|
177
|
+
if kind == _ALL:
|
|
178
|
+
return args[1:]
|
|
179
|
+
if kind == _TWO:
|
|
180
|
+
return args[1:3]
|
|
181
|
+
if kind == _MSET:
|
|
182
|
+
return args[1::2]
|
|
183
|
+
if kind == _LASTISTIMEOUT or kind == _LASTISPATH:
|
|
184
|
+
return args[1 : n - 1]
|
|
185
|
+
if kind == _COUNT1:
|
|
186
|
+
return _counted(args, 1)
|
|
187
|
+
if kind == _COUNT2:
|
|
188
|
+
return _counted(args, 2)
|
|
189
|
+
if kind == _STORECOUNT:
|
|
190
|
+
return [args[1]] + _counted(args, 2)
|
|
191
|
+
if kind == _STREAMS:
|
|
192
|
+
for i in range(1, n):
|
|
193
|
+
if args[i].upper() == b"STREAMS":
|
|
194
|
+
return args[i + 1 : i + 1 + (n - i - 1) // 2]
|
|
195
|
+
return []
|
|
196
|
+
if kind == _BITOP:
|
|
197
|
+
return args[2:]
|
|
198
|
+
if kind == _GEO:
|
|
199
|
+
keys = [args[1]]
|
|
200
|
+
for i in range(2, n - 1):
|
|
201
|
+
if args[i].upper() in (b"STORE", b"STOREDIST"):
|
|
202
|
+
keys.append(args[i + 1])
|
|
203
|
+
return keys
|
|
204
|
+
if kind == _SLOT0 or kind == _SCAN:
|
|
205
|
+
return []
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def plan(name: bytes, args: List[bytes]) -> Tuple[Any, ...]:
|
|
210
|
+
"""Decides how a command is routed. Raises :class:`CrossSlotError` before anything is sent."""
|
|
211
|
+
kind = SPEC.get(name)
|
|
212
|
+
if kind is None:
|
|
213
|
+
return (PLAN_SLOT, key_slot(args[1]) if len(args) > 1 else None)
|
|
214
|
+
if kind == _KEYLESS:
|
|
215
|
+
return (PLAN_SLOT, None)
|
|
216
|
+
if kind == _SLOT0:
|
|
217
|
+
return (PLAN_SLOT, 0)
|
|
218
|
+
if kind == _TXN:
|
|
219
|
+
raise RuvioError(_UNSUPPORTED_TXN)
|
|
220
|
+
if kind == _SCAN:
|
|
221
|
+
return (PLAN_SCAN,)
|
|
222
|
+
if kind == _FAN_SUM or kind == _FAN_FIRST or kind == _FAN_CAT:
|
|
223
|
+
return (PLAN_FAN, kind)
|
|
224
|
+
if kind == _COND:
|
|
225
|
+
if len(args) > 1 and (name, args[1].upper()) in _FAN_SUB:
|
|
226
|
+
return (PLAN_FAN, _FAN_FIRST)
|
|
227
|
+
return (PLAN_SLOT, None)
|
|
228
|
+
keys = command_keys(name, args)
|
|
229
|
+
if not keys:
|
|
230
|
+
return (PLAN_SLOT, None)
|
|
231
|
+
first = key_slot(keys[0])
|
|
232
|
+
for k in keys[1:]:
|
|
233
|
+
if key_slot(k) != first:
|
|
234
|
+
if name in SPLITTABLE:
|
|
235
|
+
return (PLAN_SPLIT, keys)
|
|
236
|
+
raise CrossSlotError(
|
|
237
|
+
f"CROSSSLOT {name.decode()} keys don't hash to the same slot (nothing was sent)"
|
|
238
|
+
)
|
|
239
|
+
return (PLAN_SLOT, first)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def pipeline_slot(frame: bytes) -> Optional[int]:
|
|
243
|
+
"""Slot of one pipelined command (None = keyless). Fan-out and cross-slot commands are refused."""
|
|
244
|
+
name, key = frame_head(frame)
|
|
245
|
+
name = name.upper()
|
|
246
|
+
kind = SPEC.get(name)
|
|
247
|
+
if kind is None:
|
|
248
|
+
return None if key is None else key_slot(key)
|
|
249
|
+
p = plan(name, split_frame(frame))
|
|
250
|
+
if p[0] == PLAN_SLOT:
|
|
251
|
+
return p[1]
|
|
252
|
+
if p[0] == PLAN_SPLIT:
|
|
253
|
+
raise CrossSlotError(
|
|
254
|
+
f"CROSSSLOT {name.decode()} keys don't hash to the same slot; split it or call it outside the pipeline"
|
|
255
|
+
)
|
|
256
|
+
raise RuvioError(f"{name.decode()} fans out to every node and cannot be pipelined on a cluster client")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def pipeline_groups(
|
|
260
|
+
frames: List[bytes], table: List[int]
|
|
261
|
+
) -> Dict[int, Tuple[List[int], List[bytes]]]:
|
|
262
|
+
"""Groups pipelined frames by node index (``-1`` = keyless: the seed), in one pass.
|
|
263
|
+
|
|
264
|
+
``table`` is the slot -> node index list. Raises before anything is sent if a command
|
|
265
|
+
cannot be routed (cross-slot multi-key command, fan-out command)."""
|
|
266
|
+
groups: Dict[int, Tuple[List[int], List[bytes]]] = {}
|
|
267
|
+
spec = SPEC
|
|
268
|
+
for i, f in enumerate(frames):
|
|
269
|
+
name, key = frame_head(f)
|
|
270
|
+
if not name.isupper():
|
|
271
|
+
name = name.upper()
|
|
272
|
+
if name in spec:
|
|
273
|
+
slot = pipeline_slot(f)
|
|
274
|
+
else:
|
|
275
|
+
slot = None if key is None else key_slot(key)
|
|
276
|
+
ni = -1 if slot is None else table[slot]
|
|
277
|
+
g = groups.get(ni)
|
|
278
|
+
if g is None:
|
|
279
|
+
g = groups[ni] = ([], [])
|
|
280
|
+
g[0].append(i)
|
|
281
|
+
g[1].append(f)
|
|
282
|
+
return groups
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --------------------------------------------------------------------------- split commands
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def split_groups(
|
|
289
|
+
name: bytes, args: List[bytes], keys: List[bytes], node_of: Any
|
|
290
|
+
) -> Dict[Any, List[Tuple[List[int], bytes]]]:
|
|
291
|
+
"""Splits MGET/DEL/MSET... into one sub-command per slot (the server wants one slot per command)
|
|
292
|
+
and groups the sub-commands by owning node.
|
|
293
|
+
|
|
294
|
+
``node_of(slot)`` returns a hashable node handle. Returns ``{node: [(key positions, frame)]}``."""
|
|
295
|
+
pairs = name == b"MSET"
|
|
296
|
+
by_slot: Dict[int, List[int]] = {}
|
|
297
|
+
for i, k in enumerate(keys):
|
|
298
|
+
by_slot.setdefault(key_slot(k), []).append(i)
|
|
299
|
+
out: Dict[Any, List[Tuple[List[int], bytes]]] = {}
|
|
300
|
+
for slot, pos in by_slot.items():
|
|
301
|
+
if pairs:
|
|
302
|
+
sub: List[bytes] = []
|
|
303
|
+
for i in pos:
|
|
304
|
+
sub.append(args[1 + 2 * i])
|
|
305
|
+
sub.append(args[2 + 2 * i])
|
|
306
|
+
else:
|
|
307
|
+
sub = [keys[i] for i in pos]
|
|
308
|
+
out.setdefault(node_of(slot), []).append((pos, encode_args((name, *sub))))
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def merge_split(name: bytes, nkeys: int, parts: List[Tuple[List[int], Any]]) -> Any:
|
|
313
|
+
"""Re-assembles the per-node replies (``parts`` = ``[(positions, reply)]``) in the caller's order."""
|
|
314
|
+
for _, r in parts:
|
|
315
|
+
if isinstance(r, ResponseError):
|
|
316
|
+
return r
|
|
317
|
+
if name == b"MGET":
|
|
318
|
+
merged: List[Any] = [None] * nkeys
|
|
319
|
+
for pos, r in parts:
|
|
320
|
+
for i, v in zip(pos, r):
|
|
321
|
+
merged[i] = v
|
|
322
|
+
return merged
|
|
323
|
+
if name == b"MSET":
|
|
324
|
+
return "OK"
|
|
325
|
+
return sum(r for _, r in parts)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --------------------------------------------------------------------------- SCAN across nodes
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def scan_prepare(args: List[bytes], nodes: int) -> Tuple[int, bytes]:
|
|
332
|
+
"""The client cursor is ``server_cursor * nodes + node``: 0 starts on the first node and comes
|
|
333
|
+
back as 0 only after the last one. Returns ``(node index, frame for that node)``."""
|
|
334
|
+
try:
|
|
335
|
+
cursor = int(args[1])
|
|
336
|
+
except (IndexError, ValueError):
|
|
337
|
+
raise ResponseError("ERR invalid cursor") from None
|
|
338
|
+
if cursor < 0:
|
|
339
|
+
raise ResponseError("ERR invalid cursor")
|
|
340
|
+
node = cursor % nodes
|
|
341
|
+
return node, encode_args((args[0], str(cursor // nodes).encode(), *args[2:]))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def scan_finish(reply: Any, node: int, nodes: int) -> Any:
|
|
345
|
+
if not isinstance(reply, (list, tuple)) or len(reply) != 2:
|
|
346
|
+
return reply
|
|
347
|
+
nxt = int(reply[0])
|
|
348
|
+
new = nxt * nodes + node if nxt else (node + 1 if node + 1 < nodes else 0)
|
|
349
|
+
text = str(new)
|
|
350
|
+
return [text.encode() if type(reply[0]) is bytes else text, reply[1]]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# --------------------------------------------------------------------------- topology
|
|
354
|
+
|
|
355
|
+
CLUSTER_SLOTS = encode("CLUSTER SLOTS", ())
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
class Topology:
|
|
359
|
+
"""16384-entry slot -> node index table plus the node addresses (in slot order)."""
|
|
360
|
+
|
|
361
|
+
__slots__ = ("slots", "nodes")
|
|
362
|
+
|
|
363
|
+
def __init__(self, slots: List[int], nodes: List[Tuple[str, int]]) -> None:
|
|
364
|
+
self.slots = slots
|
|
365
|
+
self.nodes = nodes
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _text(v: Any) -> str:
|
|
369
|
+
return v.decode() if type(v) is bytes else str(v)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def parse_slots(reply: Any, seed_host: str) -> Optional[Topology]:
|
|
373
|
+
"""Reads a ``CLUSTER SLOTS`` reply. None for an error or an empty map."""
|
|
374
|
+
if not isinstance(reply, list) or not reply:
|
|
375
|
+
return None
|
|
376
|
+
slots = [-1] * SLOT_COUNT
|
|
377
|
+
nodes: List[Tuple[str, int]] = []
|
|
378
|
+
index: Dict[Tuple[str, int], int] = {}
|
|
379
|
+
try:
|
|
380
|
+
for rng in sorted(reply, key=lambda r: int(r[0])):
|
|
381
|
+
first, last, primary = int(rng[0]), int(rng[1]), rng[2]
|
|
382
|
+
host = _text(primary[0])
|
|
383
|
+
if host in ("", "0.0.0.0", "::"):
|
|
384
|
+
host = seed_host
|
|
385
|
+
addr = (host, int(primary[1]))
|
|
386
|
+
idx = index.get(addr)
|
|
387
|
+
if idx is None:
|
|
388
|
+
idx = index[addr] = len(nodes)
|
|
389
|
+
nodes.append(addr)
|
|
390
|
+
for s in range(max(0, first), min(SLOT_COUNT - 1, last) + 1):
|
|
391
|
+
slots[s] = idx
|
|
392
|
+
except (IndexError, TypeError, ValueError):
|
|
393
|
+
return None
|
|
394
|
+
return Topology(slots, nodes)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def parse_moved(err: BaseException) -> Optional[Tuple[int, str, int]]:
|
|
398
|
+
"""``(slot, host, port)`` of a ``MOVED slot host:port`` error; None for anything else."""
|
|
399
|
+
msg = err.args[0] if err.args else ""
|
|
400
|
+
if not isinstance(msg, str) or not msg.startswith("MOVED "):
|
|
401
|
+
return None
|
|
402
|
+
parts = msg.split()
|
|
403
|
+
if len(parts) < 3:
|
|
404
|
+
return None
|
|
405
|
+
host, _, port = parts[2].rpartition(":")
|
|
406
|
+
try:
|
|
407
|
+
return int(parts[1]), host, int(port)
|
|
408
|
+
except ValueError:
|
|
409
|
+
return None
|