coredis 5.5.0__cp313-cp313-macosx_11_0_arm64.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.
- 22fe76227e35f92ab5c3__mypyc.cpython-313-darwin.so +0 -0
- coredis/__init__.py +42 -0
- coredis/_enum.py +42 -0
- coredis/_json.py +11 -0
- coredis/_packer.cpython-313-darwin.so +0 -0
- coredis/_packer.py +71 -0
- coredis/_protocols.py +50 -0
- coredis/_py_311_typing.py +20 -0
- coredis/_py_312_typing.py +17 -0
- coredis/_sidecar.py +114 -0
- coredis/_utils.cpython-313-darwin.so +0 -0
- coredis/_utils.py +440 -0
- coredis/_version.py +34 -0
- coredis/_version.pyi +1 -0
- coredis/cache.py +801 -0
- coredis/client/__init__.py +6 -0
- coredis/client/basic.py +1240 -0
- coredis/client/cluster.py +1265 -0
- coredis/commands/__init__.py +64 -0
- coredis/commands/_key_spec.py +517 -0
- coredis/commands/_utils.py +108 -0
- coredis/commands/_validators.py +159 -0
- coredis/commands/_wrappers.py +175 -0
- coredis/commands/bitfield.py +110 -0
- coredis/commands/constants.py +662 -0
- coredis/commands/core.py +8484 -0
- coredis/commands/function.py +408 -0
- coredis/commands/monitor.py +168 -0
- coredis/commands/pubsub.py +905 -0
- coredis/commands/request.py +108 -0
- coredis/commands/script.py +296 -0
- coredis/commands/sentinel.py +246 -0
- coredis/config.py +50 -0
- coredis/connection.py +906 -0
- coredis/constants.cpython-313-darwin.so +0 -0
- coredis/constants.py +37 -0
- coredis/credentials.py +45 -0
- coredis/exceptions.py +360 -0
- coredis/experimental/__init__.py +1 -0
- coredis/globals.py +23 -0
- coredis/modules/__init__.py +121 -0
- coredis/modules/autocomplete.py +138 -0
- coredis/modules/base.py +262 -0
- coredis/modules/filters.py +1319 -0
- coredis/modules/graph.py +362 -0
- coredis/modules/json.py +691 -0
- coredis/modules/response/__init__.py +0 -0
- coredis/modules/response/_callbacks/__init__.py +0 -0
- coredis/modules/response/_callbacks/autocomplete.py +42 -0
- coredis/modules/response/_callbacks/graph.py +237 -0
- coredis/modules/response/_callbacks/json.py +21 -0
- coredis/modules/response/_callbacks/search.py +221 -0
- coredis/modules/response/_callbacks/timeseries.py +158 -0
- coredis/modules/response/types.py +179 -0
- coredis/modules/search.py +1089 -0
- coredis/modules/timeseries.py +1139 -0
- coredis/parser.cpython-313-darwin.so +0 -0
- coredis/parser.py +344 -0
- coredis/pipeline.py +1225 -0
- coredis/pool/__init__.py +11 -0
- coredis/pool/basic.py +453 -0
- coredis/pool/cluster.py +517 -0
- coredis/pool/nodemanager.py +340 -0
- coredis/py.typed +0 -0
- coredis/recipes/__init__.py +0 -0
- coredis/recipes/credentials/__init__.py +5 -0
- coredis/recipes/credentials/iam_provider.py +63 -0
- coredis/recipes/locks/__init__.py +5 -0
- coredis/recipes/locks/extend.lua +17 -0
- coredis/recipes/locks/lua_lock.py +281 -0
- coredis/recipes/locks/release.lua +10 -0
- coredis/response/__init__.py +5 -0
- coredis/response/_callbacks/__init__.py +538 -0
- coredis/response/_callbacks/acl.py +32 -0
- coredis/response/_callbacks/cluster.py +183 -0
- coredis/response/_callbacks/command.py +86 -0
- coredis/response/_callbacks/connection.py +31 -0
- coredis/response/_callbacks/geo.py +58 -0
- coredis/response/_callbacks/hash.py +85 -0
- coredis/response/_callbacks/keys.py +59 -0
- coredis/response/_callbacks/module.py +33 -0
- coredis/response/_callbacks/script.py +85 -0
- coredis/response/_callbacks/sentinel.py +179 -0
- coredis/response/_callbacks/server.py +241 -0
- coredis/response/_callbacks/sets.py +44 -0
- coredis/response/_callbacks/sorted_set.py +204 -0
- coredis/response/_callbacks/streams.py +185 -0
- coredis/response/_callbacks/strings.py +70 -0
- coredis/response/_callbacks/vector_sets.py +159 -0
- coredis/response/_utils.py +33 -0
- coredis/response/types.py +416 -0
- coredis/retry.py +233 -0
- coredis/sentinel.py +477 -0
- coredis/stream.py +369 -0
- coredis/tokens.py +2286 -0
- coredis/typing.py +593 -0
- coredis-5.5.0.dist-info/METADATA +211 -0
- coredis-5.5.0.dist-info/RECORD +100 -0
- coredis-5.5.0.dist-info/WHEEL +6 -0
- coredis-5.5.0.dist-info/licenses/LICENSE +23 -0
coredis/pipeline.py
ADDED
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import functools
|
|
5
|
+
import inspect
|
|
6
|
+
import sys
|
|
7
|
+
import textwrap
|
|
8
|
+
import warnings
|
|
9
|
+
from abc import ABCMeta
|
|
10
|
+
from concurrent.futures import CancelledError
|
|
11
|
+
from types import TracebackType
|
|
12
|
+
from typing import Any, cast
|
|
13
|
+
|
|
14
|
+
from deprecated.sphinx import deprecated
|
|
15
|
+
|
|
16
|
+
from coredis._utils import b, hash_slot, nativestr
|
|
17
|
+
from coredis.client import Client, RedisCluster
|
|
18
|
+
from coredis.commands import CommandRequest, CommandResponseT
|
|
19
|
+
from coredis.commands._key_spec import KeySpec
|
|
20
|
+
from coredis.commands.constants import CommandName, NodeFlag
|
|
21
|
+
from coredis.commands.request import TransformedResponse
|
|
22
|
+
from coredis.commands.script import Script
|
|
23
|
+
from coredis.connection import BaseConnection, ClusterConnection, CommandInvocation, Connection
|
|
24
|
+
from coredis.exceptions import (
|
|
25
|
+
AskError,
|
|
26
|
+
ClusterCrossSlotError,
|
|
27
|
+
ClusterDownError,
|
|
28
|
+
ClusterTransactionError,
|
|
29
|
+
ConnectionError,
|
|
30
|
+
ExecAbortError,
|
|
31
|
+
MovedError,
|
|
32
|
+
RedisClusterException,
|
|
33
|
+
RedisError,
|
|
34
|
+
ResponseError,
|
|
35
|
+
TimeoutError,
|
|
36
|
+
TryAgainError,
|
|
37
|
+
WatchError,
|
|
38
|
+
)
|
|
39
|
+
from coredis.pool import ClusterConnectionPool, ConnectionPool
|
|
40
|
+
from coredis.pool.nodemanager import ManagedNode
|
|
41
|
+
from coredis.response._callbacks import (
|
|
42
|
+
AnyStrCallback,
|
|
43
|
+
AsyncPreProcessingCallback,
|
|
44
|
+
BoolCallback,
|
|
45
|
+
BoolsCallback,
|
|
46
|
+
NoopCallback,
|
|
47
|
+
SimpleStringCallback,
|
|
48
|
+
)
|
|
49
|
+
from coredis.retry import ConstantRetryPolicy, retryable
|
|
50
|
+
from coredis.typing import (
|
|
51
|
+
AnyStr,
|
|
52
|
+
Awaitable,
|
|
53
|
+
Callable,
|
|
54
|
+
ExecutionParameters,
|
|
55
|
+
Generator,
|
|
56
|
+
Iterable,
|
|
57
|
+
KeyT,
|
|
58
|
+
Parameters,
|
|
59
|
+
ParamSpec,
|
|
60
|
+
RedisCommand,
|
|
61
|
+
RedisCommandP,
|
|
62
|
+
RedisValueT,
|
|
63
|
+
ResponseType,
|
|
64
|
+
Self,
|
|
65
|
+
StringT,
|
|
66
|
+
T_co,
|
|
67
|
+
TypeVar,
|
|
68
|
+
Unpack,
|
|
69
|
+
ValueT,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
P = ParamSpec("P")
|
|
73
|
+
R = TypeVar("R")
|
|
74
|
+
T = TypeVar("T")
|
|
75
|
+
|
|
76
|
+
ERRORS_ALLOW_RETRY = (
|
|
77
|
+
MovedError,
|
|
78
|
+
AskError,
|
|
79
|
+
TryAgainError,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
UNWATCH_COMMANDS = {CommandName.DISCARD, CommandName.EXEC, CommandName.UNWATCH}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def wrap_pipeline_method(
|
|
86
|
+
kls: PipelineMeta, func: Callable[P, Awaitable[R]]
|
|
87
|
+
) -> Callable[P, Awaitable[R]]:
|
|
88
|
+
@functools.wraps(func)
|
|
89
|
+
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Awaitable[R]:
|
|
90
|
+
return func(*args, **kwargs)
|
|
91
|
+
|
|
92
|
+
wrapper.__doc__ = textwrap.dedent(wrapper.__doc__ or "")
|
|
93
|
+
wrapper.__doc__ = f"""
|
|
94
|
+
.. note:: Pipeline variant of :meth:`coredis.Redis.{func.__name__}` that does not execute
|
|
95
|
+
immediately and instead pushes the command into a stack for batch send.
|
|
96
|
+
|
|
97
|
+
The return value can be retrieved either as part of the tuple returned by
|
|
98
|
+
:meth:`~{kls.__name__}.execute` or by awaiting the :class:`~coredis.commands.CommandRequest`
|
|
99
|
+
instance after calling :meth:`~{kls.__name__}.execute`
|
|
100
|
+
|
|
101
|
+
{wrapper.__doc__}
|
|
102
|
+
"""
|
|
103
|
+
return wrapper
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PipelineCommandRequest(CommandRequest[CommandResponseT]):
|
|
107
|
+
"""
|
|
108
|
+
Command request used within a pipeline. Handles immediate execution for WATCH or
|
|
109
|
+
watched commands outside explicit transactions, otherwise queues the command.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
client: Pipeline[Any] | ClusterPipeline[Any]
|
|
113
|
+
queued_response: Awaitable[bytes | str]
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
client: Pipeline[Any] | ClusterPipeline[Any],
|
|
118
|
+
name: bytes,
|
|
119
|
+
*arguments: ValueT,
|
|
120
|
+
callback: Callable[..., CommandResponseT],
|
|
121
|
+
execution_parameters: ExecutionParameters | None = None,
|
|
122
|
+
parent: CommandRequest[Any] | None = None,
|
|
123
|
+
) -> None:
|
|
124
|
+
super().__init__(
|
|
125
|
+
client,
|
|
126
|
+
name,
|
|
127
|
+
*arguments,
|
|
128
|
+
callback=callback,
|
|
129
|
+
execution_parameters=execution_parameters,
|
|
130
|
+
)
|
|
131
|
+
if not parent:
|
|
132
|
+
if (client.watching or name == CommandName.WATCH) and not client.explicit_transaction:
|
|
133
|
+
self.response = client.immediate_execute_command(
|
|
134
|
+
self, callback=callback, **self.execution_parameters
|
|
135
|
+
)
|
|
136
|
+
else:
|
|
137
|
+
client.pipeline_execute_command(self) # type: ignore[arg-type]
|
|
138
|
+
self.parent = parent
|
|
139
|
+
|
|
140
|
+
def transform(
|
|
141
|
+
self, transformer: type[TransformedResponse]
|
|
142
|
+
) -> CommandRequest[TransformedResponse]:
|
|
143
|
+
transform_func = functools.partial(
|
|
144
|
+
self.type_adapter.deserialize,
|
|
145
|
+
return_type=transformer,
|
|
146
|
+
)
|
|
147
|
+
return cast(type[PipelineCommandRequest[TransformedResponse]], self.__class__)(
|
|
148
|
+
self.client,
|
|
149
|
+
self.name,
|
|
150
|
+
*self.arguments,
|
|
151
|
+
callback=lambda resp, **k: transform_func(resp),
|
|
152
|
+
execution_parameters=self.execution_parameters,
|
|
153
|
+
parent=self,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
async def __backward_compatibility_return(self) -> Pipeline[Any] | ClusterPipeline[Any]:
|
|
157
|
+
"""
|
|
158
|
+
For backward compatibility: returns the pipeline instance when awaited before execute().
|
|
159
|
+
"""
|
|
160
|
+
return self.client
|
|
161
|
+
|
|
162
|
+
def __await__(self) -> Generator[None, None, CommandResponseT]:
|
|
163
|
+
if hasattr(self, "response"):
|
|
164
|
+
return self.response.__await__()
|
|
165
|
+
elif self.parent:
|
|
166
|
+
|
|
167
|
+
async def _transformed() -> CommandResponseT:
|
|
168
|
+
if (r := await self.parent) == self.client: # type: ignore
|
|
169
|
+
return r # type: ignore
|
|
170
|
+
else:
|
|
171
|
+
return self.callback(r)
|
|
172
|
+
|
|
173
|
+
return _transformed().__await__()
|
|
174
|
+
else:
|
|
175
|
+
warnings.warn(
|
|
176
|
+
"""
|
|
177
|
+
Awaiting a pipeline command response before calling `execute()` on the pipeline instance
|
|
178
|
+
has no effect and returns the pipeline instance itself for backward compatibility.
|
|
179
|
+
|
|
180
|
+
To add commands to a pipeline simply call the methods synchronously. The awaitable response
|
|
181
|
+
can be awaited after calling `execute()` to retrieve a statically typed response if required.
|
|
182
|
+
""",
|
|
183
|
+
stacklevel=2,
|
|
184
|
+
)
|
|
185
|
+
return self.__backward_compatibility_return().__await__() # type: ignore[return-value]
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class ClusterPipelineCommandRequest(PipelineCommandRequest[CommandResponseT]):
|
|
189
|
+
"""
|
|
190
|
+
Command request for cluster pipelines, tracks position and result for cluster routing.
|
|
191
|
+
"""
|
|
192
|
+
|
|
193
|
+
def __init__(
|
|
194
|
+
self,
|
|
195
|
+
client: ClusterPipeline[Any],
|
|
196
|
+
name: bytes,
|
|
197
|
+
*arguments: ValueT,
|
|
198
|
+
callback: Callable[..., CommandResponseT],
|
|
199
|
+
execution_parameters: ExecutionParameters | None = None,
|
|
200
|
+
parent: CommandRequest[Any] | None = None,
|
|
201
|
+
) -> None:
|
|
202
|
+
self.position: int = 0
|
|
203
|
+
self.result: Any | None = None
|
|
204
|
+
self.asking: bool = False
|
|
205
|
+
super().__init__(
|
|
206
|
+
client,
|
|
207
|
+
name,
|
|
208
|
+
*arguments,
|
|
209
|
+
callback=callback,
|
|
210
|
+
execution_parameters=execution_parameters,
|
|
211
|
+
parent=parent,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class NodeCommands:
|
|
216
|
+
"""
|
|
217
|
+
Helper for grouping and executing commands on a single cluster node, handling transactions if needed.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(
|
|
221
|
+
self,
|
|
222
|
+
client: RedisCluster[AnyStr],
|
|
223
|
+
connection: ClusterConnection,
|
|
224
|
+
in_transaction: bool = False,
|
|
225
|
+
timeout: float | None = None,
|
|
226
|
+
):
|
|
227
|
+
self.client: RedisCluster[Any] = client
|
|
228
|
+
self.connection = connection
|
|
229
|
+
self.commands: list[ClusterPipelineCommandRequest[Any]] = []
|
|
230
|
+
self.in_transaction = in_transaction
|
|
231
|
+
self.timeout = timeout
|
|
232
|
+
self.multi_cmd: asyncio.Future[ResponseType] | None = None
|
|
233
|
+
self.exec_cmd: asyncio.Future[ResponseType] | None = None
|
|
234
|
+
|
|
235
|
+
def extend(self, c: list[ClusterPipelineCommandRequest[Any]]) -> None:
|
|
236
|
+
self.commands.extend(c)
|
|
237
|
+
|
|
238
|
+
def append(self, c: ClusterPipelineCommandRequest[Any]) -> None:
|
|
239
|
+
self.commands.append(c)
|
|
240
|
+
|
|
241
|
+
async def write(self) -> None:
|
|
242
|
+
connection = self.connection
|
|
243
|
+
commands = self.commands
|
|
244
|
+
|
|
245
|
+
# Reset results for all commands before writing.
|
|
246
|
+
for c in commands:
|
|
247
|
+
c.result = None
|
|
248
|
+
|
|
249
|
+
# Batch all commands into a single request for efficiency.
|
|
250
|
+
try:
|
|
251
|
+
if self.in_transaction:
|
|
252
|
+
self.multi_cmd = await connection.create_request(
|
|
253
|
+
CommandName.MULTI, timeout=self.timeout
|
|
254
|
+
)
|
|
255
|
+
requests = await connection.create_requests(
|
|
256
|
+
[
|
|
257
|
+
CommandInvocation(
|
|
258
|
+
cmd.name,
|
|
259
|
+
cmd.arguments,
|
|
260
|
+
(
|
|
261
|
+
bool(cmd.execution_parameters.get("decode"))
|
|
262
|
+
if cmd.execution_parameters.get("decode")
|
|
263
|
+
else None
|
|
264
|
+
),
|
|
265
|
+
None,
|
|
266
|
+
)
|
|
267
|
+
for cmd in commands
|
|
268
|
+
],
|
|
269
|
+
timeout=self.timeout,
|
|
270
|
+
)
|
|
271
|
+
if self.in_transaction:
|
|
272
|
+
self.exec_cmd = await connection.create_request(
|
|
273
|
+
CommandName.EXEC, timeout=self.timeout
|
|
274
|
+
)
|
|
275
|
+
for i, cmd in enumerate(commands):
|
|
276
|
+
cmd.response = requests[i]
|
|
277
|
+
except (ConnectionError, TimeoutError) as e:
|
|
278
|
+
for c in commands:
|
|
279
|
+
c.result = e
|
|
280
|
+
|
|
281
|
+
async def read(self) -> None:
|
|
282
|
+
connection = self.connection
|
|
283
|
+
success = True
|
|
284
|
+
multi_result = None
|
|
285
|
+
if self.multi_cmd:
|
|
286
|
+
multi_result = await self.multi_cmd
|
|
287
|
+
success = multi_result in {b"OK", "OK"}
|
|
288
|
+
for c in self.commands:
|
|
289
|
+
if c.result is None:
|
|
290
|
+
try:
|
|
291
|
+
c.result = await c.response if c.response else None
|
|
292
|
+
except ExecAbortError:
|
|
293
|
+
raise
|
|
294
|
+
except (ConnectionError, TimeoutError, RedisError) as e:
|
|
295
|
+
success = False
|
|
296
|
+
c.result = e
|
|
297
|
+
if self.in_transaction and self.exec_cmd:
|
|
298
|
+
if success:
|
|
299
|
+
res = await self.exec_cmd
|
|
300
|
+
if res:
|
|
301
|
+
transaction_result = cast(list[ResponseType], res)
|
|
302
|
+
else:
|
|
303
|
+
raise WatchError("Watched variable changed.")
|
|
304
|
+
for idx, c in enumerate(
|
|
305
|
+
[
|
|
306
|
+
_c
|
|
307
|
+
for _c in sorted(self.commands, key=lambda x: x.position)
|
|
308
|
+
if _c.name not in {CommandName.MULTI, CommandName.EXEC}
|
|
309
|
+
]
|
|
310
|
+
):
|
|
311
|
+
if isinstance(c.callback, AsyncPreProcessingCallback):
|
|
312
|
+
await c.callback.pre_process(self.client, transaction_result[idx])
|
|
313
|
+
c.result = c.callback(
|
|
314
|
+
transaction_result[idx],
|
|
315
|
+
version=connection.protocol_version,
|
|
316
|
+
)
|
|
317
|
+
elif isinstance(multi_result, BaseException):
|
|
318
|
+
raise multi_result
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class PipelineMeta(ABCMeta):
|
|
322
|
+
RESULT_CALLBACKS: dict[str, Callable[..., Any]]
|
|
323
|
+
NODES_FLAGS: dict[str, NodeFlag]
|
|
324
|
+
|
|
325
|
+
def __new__(
|
|
326
|
+
cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]
|
|
327
|
+
) -> PipelineMeta:
|
|
328
|
+
kls = super().__new__(cls, name, bases, namespace)
|
|
329
|
+
|
|
330
|
+
for name, method in PipelineMeta.get_methods(kls).items():
|
|
331
|
+
if getattr(method, "__coredis_command", None):
|
|
332
|
+
setattr(kls, name, wrap_pipeline_method(kls, method))
|
|
333
|
+
|
|
334
|
+
return kls
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def get_methods(kls: PipelineMeta) -> dict[str, Callable[..., Any]]:
|
|
338
|
+
return dict(k for k in inspect.getmembers(kls) if inspect.isfunction(k[1]))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class ClusterPipelineMeta(PipelineMeta):
|
|
342
|
+
def __new__(
|
|
343
|
+
cls, name: str, bases: tuple[type, ...], namespace: dict[str, object]
|
|
344
|
+
) -> PipelineMeta:
|
|
345
|
+
kls = super().__new__(cls, name, bases, namespace)
|
|
346
|
+
for name, method in ClusterPipelineMeta.get_methods(kls).items():
|
|
347
|
+
cmd = getattr(method, "__coredis_command", None)
|
|
348
|
+
if cmd:
|
|
349
|
+
if cmd.cluster.route:
|
|
350
|
+
kls.NODES_FLAGS[cmd.command] = cmd.cluster.route
|
|
351
|
+
if cmd.cluster.multi_node:
|
|
352
|
+
kls.RESULT_CALLBACKS[cmd.command] = cmd.cluster.combine or (lambda r, **_: r)
|
|
353
|
+
else:
|
|
354
|
+
kls.RESULT_CALLBACKS[cmd.command] = lambda response, **_: list(
|
|
355
|
+
response.values()
|
|
356
|
+
).pop()
|
|
357
|
+
return kls
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class Pipeline(Client[AnyStr], metaclass=PipelineMeta):
|
|
361
|
+
"""
|
|
362
|
+
Pipeline for batching multiple commands to a Redis server.
|
|
363
|
+
Supports transactions and command stacking.
|
|
364
|
+
|
|
365
|
+
All commands executed within a pipeline are wrapped with MULTI and EXEC
|
|
366
|
+
calls when :paramref:`transaction` is ``True``.
|
|
367
|
+
|
|
368
|
+
Any command raising an exception does *not* halt the execution of
|
|
369
|
+
subsequent commands in the pipeline. Instead, the exception is caught
|
|
370
|
+
and its instance is placed into the response list returned by :meth:`execute`
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
command_stack: list[PipelineCommandRequest[Any]]
|
|
374
|
+
connection_pool: ConnectionPool
|
|
375
|
+
|
|
376
|
+
def __init__(
|
|
377
|
+
self,
|
|
378
|
+
client: Client[AnyStr],
|
|
379
|
+
transaction: bool | None,
|
|
380
|
+
watches: Parameters[KeyT] | None = None,
|
|
381
|
+
timeout: float | None = None,
|
|
382
|
+
) -> None:
|
|
383
|
+
self.client: Client[AnyStr] = client
|
|
384
|
+
self.connection_pool = client.connection_pool
|
|
385
|
+
self.connection: Connection | None = None
|
|
386
|
+
self._transaction = transaction
|
|
387
|
+
self.watching = False
|
|
388
|
+
self.watches: Parameters[KeyT] | None = watches or None
|
|
389
|
+
self.command_stack = []
|
|
390
|
+
self.cache = None
|
|
391
|
+
self.explicit_transaction = False
|
|
392
|
+
self.scripts: set[Script[AnyStr]] = set()
|
|
393
|
+
self.timeout = timeout
|
|
394
|
+
self.type_adapter = client.type_adapter
|
|
395
|
+
|
|
396
|
+
async def __aenter__(self) -> Pipeline[AnyStr]:
|
|
397
|
+
return await self.get_instance()
|
|
398
|
+
|
|
399
|
+
async def __aexit__(
|
|
400
|
+
self,
|
|
401
|
+
exc_type: type[BaseException] | None,
|
|
402
|
+
exc_value: BaseException | None,
|
|
403
|
+
traceback: TracebackType | None,
|
|
404
|
+
) -> None:
|
|
405
|
+
await self.clear()
|
|
406
|
+
|
|
407
|
+
def __await__(self) -> Generator[Any, Any, Pipeline[AnyStr]]:
|
|
408
|
+
return self.get_instance().__await__()
|
|
409
|
+
|
|
410
|
+
def __len__(self) -> int:
|
|
411
|
+
return len(self.command_stack)
|
|
412
|
+
|
|
413
|
+
def __bool__(self) -> bool:
|
|
414
|
+
return True
|
|
415
|
+
|
|
416
|
+
async def get_instance(self) -> Pipeline[AnyStr]:
|
|
417
|
+
return self
|
|
418
|
+
|
|
419
|
+
def create_request(
|
|
420
|
+
self,
|
|
421
|
+
name: bytes,
|
|
422
|
+
*arguments: ValueT,
|
|
423
|
+
callback: Callable[..., T_co],
|
|
424
|
+
execution_parameters: ExecutionParameters | None = None,
|
|
425
|
+
) -> CommandRequest[T_co]:
|
|
426
|
+
"""
|
|
427
|
+
:meta private:
|
|
428
|
+
"""
|
|
429
|
+
return PipelineCommandRequest(
|
|
430
|
+
self, name, *arguments, callback=callback, execution_parameters=execution_parameters
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
async def clear(self) -> None:
|
|
434
|
+
"""
|
|
435
|
+
Clear the pipeline, reset state, and release the connection back to the pool.
|
|
436
|
+
"""
|
|
437
|
+
self.command_stack.clear()
|
|
438
|
+
self.scripts = set()
|
|
439
|
+
# Reset connection state if we were watching something.
|
|
440
|
+
if self.watching and self.connection:
|
|
441
|
+
try:
|
|
442
|
+
request = await self.connection.create_request(CommandName.UNWATCH, decode=False)
|
|
443
|
+
await request
|
|
444
|
+
except ConnectionError:
|
|
445
|
+
self.connection.disconnect()
|
|
446
|
+
# Reset pipeline state and release connection if needed.
|
|
447
|
+
self.watching = False
|
|
448
|
+
self.watches = []
|
|
449
|
+
self.explicit_transaction = False
|
|
450
|
+
if self.connection:
|
|
451
|
+
self.connection_pool.release(self.connection)
|
|
452
|
+
self.connection = None
|
|
453
|
+
|
|
454
|
+
#: :meta private:
|
|
455
|
+
reset_pipeline = clear
|
|
456
|
+
|
|
457
|
+
@deprecated(
|
|
458
|
+
"The reset method in pipelines clashes with the redis ``RESET`` command. Use :meth:`clear` instead",
|
|
459
|
+
"5.0.0",
|
|
460
|
+
)
|
|
461
|
+
def reset(self) -> CommandRequest[None]:
|
|
462
|
+
"""
|
|
463
|
+
Deprecated. Use :meth:`clear` instead.
|
|
464
|
+
"""
|
|
465
|
+
return self.clear() # type: ignore
|
|
466
|
+
|
|
467
|
+
def multi(self) -> None:
|
|
468
|
+
"""
|
|
469
|
+
Start a transactional block after WATCH commands. End with `execute()`.
|
|
470
|
+
"""
|
|
471
|
+
if self.explicit_transaction:
|
|
472
|
+
raise RedisError("Cannot issue nested calls to MULTI")
|
|
473
|
+
|
|
474
|
+
if self.command_stack:
|
|
475
|
+
raise RedisError("Commands without an initial WATCH have already been issued")
|
|
476
|
+
self.explicit_transaction = True
|
|
477
|
+
|
|
478
|
+
def execute_command(
|
|
479
|
+
self,
|
|
480
|
+
command: RedisCommandP,
|
|
481
|
+
callback: Callable[..., R] = NoopCallback(),
|
|
482
|
+
**options: Unpack[ExecutionParameters],
|
|
483
|
+
) -> Awaitable[R]:
|
|
484
|
+
raise NotImplementedError
|
|
485
|
+
|
|
486
|
+
async def immediate_execute_command(
|
|
487
|
+
self,
|
|
488
|
+
command: RedisCommandP,
|
|
489
|
+
callback: Callable[..., R] = NoopCallback(),
|
|
490
|
+
**kwargs: Unpack[ExecutionParameters],
|
|
491
|
+
) -> R:
|
|
492
|
+
"""
|
|
493
|
+
Executes a command immediately, but don't auto-retry on a
|
|
494
|
+
ConnectionError if we're already WATCHing a variable. Used when
|
|
495
|
+
issuing WATCH or subsequent commands retrieving their values but before
|
|
496
|
+
MULTI is called.
|
|
497
|
+
|
|
498
|
+
:meta private:
|
|
499
|
+
"""
|
|
500
|
+
conn = self.connection
|
|
501
|
+
# if this is the first call, we need a connection
|
|
502
|
+
if not conn:
|
|
503
|
+
conn = await self.connection_pool.get_connection()
|
|
504
|
+
self.connection = conn
|
|
505
|
+
try:
|
|
506
|
+
request = await conn.create_request(
|
|
507
|
+
command.name, *command.arguments, decode=kwargs.get("decode")
|
|
508
|
+
)
|
|
509
|
+
return callback(
|
|
510
|
+
await request,
|
|
511
|
+
version=conn.protocol_version,
|
|
512
|
+
)
|
|
513
|
+
except (ConnectionError, TimeoutError):
|
|
514
|
+
conn.disconnect()
|
|
515
|
+
|
|
516
|
+
# if we're not already watching, we can safely retry the command
|
|
517
|
+
try:
|
|
518
|
+
if not self.watching:
|
|
519
|
+
request = await conn.create_request(
|
|
520
|
+
command.name, *command.arguments, decode=kwargs.get("decode")
|
|
521
|
+
)
|
|
522
|
+
return callback(await request, version=conn.protocol_version)
|
|
523
|
+
raise
|
|
524
|
+
except ConnectionError:
|
|
525
|
+
# the retry failed so cleanup.
|
|
526
|
+
conn.disconnect()
|
|
527
|
+
await self.clear()
|
|
528
|
+
raise
|
|
529
|
+
finally:
|
|
530
|
+
if command.name in UNWATCH_COMMANDS:
|
|
531
|
+
self.watching = False
|
|
532
|
+
elif command.name == CommandName.WATCH:
|
|
533
|
+
self.watching = True
|
|
534
|
+
|
|
535
|
+
def pipeline_execute_command(
|
|
536
|
+
self,
|
|
537
|
+
command: PipelineCommandRequest[R],
|
|
538
|
+
) -> None:
|
|
539
|
+
"""
|
|
540
|
+
Queue a command for execution on the next `execute()` call.
|
|
541
|
+
|
|
542
|
+
:meta private:
|
|
543
|
+
"""
|
|
544
|
+
self.command_stack.append(command)
|
|
545
|
+
|
|
546
|
+
async def _execute_transaction(
|
|
547
|
+
self,
|
|
548
|
+
connection: BaseConnection,
|
|
549
|
+
commands: list[PipelineCommandRequest[Any]],
|
|
550
|
+
raise_on_error: bool,
|
|
551
|
+
) -> tuple[Any, ...]:
|
|
552
|
+
multi_cmd = await connection.create_request(CommandName.MULTI, timeout=self.timeout)
|
|
553
|
+
requests = await connection.create_requests(
|
|
554
|
+
[
|
|
555
|
+
CommandInvocation(
|
|
556
|
+
cmd.name,
|
|
557
|
+
cmd.arguments,
|
|
558
|
+
(
|
|
559
|
+
bool(cmd.execution_parameters.get("decode"))
|
|
560
|
+
if cmd.execution_parameters.get("decode")
|
|
561
|
+
else None
|
|
562
|
+
),
|
|
563
|
+
None,
|
|
564
|
+
)
|
|
565
|
+
for cmd in commands
|
|
566
|
+
],
|
|
567
|
+
timeout=self.timeout,
|
|
568
|
+
)
|
|
569
|
+
exec_cmd = await connection.create_request(CommandName.EXEC, timeout=self.timeout)
|
|
570
|
+
for i, cmd in enumerate(commands):
|
|
571
|
+
cmd.queued_response = cast(Awaitable[StringT], requests[i])
|
|
572
|
+
|
|
573
|
+
errors: list[tuple[int, RedisError | None]] = []
|
|
574
|
+
multi_failed = False
|
|
575
|
+
|
|
576
|
+
# parse off the response for MULTI
|
|
577
|
+
# NOTE: we need to handle ResponseErrors here and continue
|
|
578
|
+
# so that we read all the additional command messages from
|
|
579
|
+
# the socket
|
|
580
|
+
try:
|
|
581
|
+
await multi_cmd
|
|
582
|
+
except RedisError:
|
|
583
|
+
multi_failed = True
|
|
584
|
+
errors.append((0, cast(RedisError, sys.exc_info()[1])))
|
|
585
|
+
|
|
586
|
+
# and all the other commands
|
|
587
|
+
for i, cmd in enumerate(commands):
|
|
588
|
+
try:
|
|
589
|
+
if cmd.queued_response:
|
|
590
|
+
assert (await cmd.queued_response) in {b"QUEUED", "QUEUED"}
|
|
591
|
+
except RedisError:
|
|
592
|
+
ex = cast(RedisError, sys.exc_info()[1])
|
|
593
|
+
self.annotate_exception(ex, i + 1, cmd.name, cmd.arguments)
|
|
594
|
+
errors.append((i, ex))
|
|
595
|
+
|
|
596
|
+
response: list[ResponseType]
|
|
597
|
+
try:
|
|
598
|
+
response = cast(
|
|
599
|
+
list[ResponseType],
|
|
600
|
+
await exec_cmd if exec_cmd else None,
|
|
601
|
+
)
|
|
602
|
+
except (ExecAbortError, ResponseError):
|
|
603
|
+
if self.explicit_transaction and not multi_failed:
|
|
604
|
+
await self.immediate_execute_command(
|
|
605
|
+
RedisCommand(name=CommandName.DISCARD, arguments=()), callback=BoolCallback()
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if errors and errors[0][1]:
|
|
609
|
+
raise errors[0][1]
|
|
610
|
+
raise
|
|
611
|
+
|
|
612
|
+
if response is None:
|
|
613
|
+
raise WatchError("Watched variable changed.")
|
|
614
|
+
|
|
615
|
+
# put any parse errors into the response
|
|
616
|
+
|
|
617
|
+
for i, e in errors:
|
|
618
|
+
response.insert(i, cast(ResponseType, e))
|
|
619
|
+
|
|
620
|
+
if len(response) != len(commands):
|
|
621
|
+
if self.connection:
|
|
622
|
+
self.connection.disconnect()
|
|
623
|
+
raise ResponseError("Wrong number of response items from pipeline execution")
|
|
624
|
+
|
|
625
|
+
# find any errors in the response and raise if necessary
|
|
626
|
+
if raise_on_error:
|
|
627
|
+
self.raise_first_error(commands, response)
|
|
628
|
+
|
|
629
|
+
# We have to run response callbacks manually
|
|
630
|
+
data: list[Any] = []
|
|
631
|
+
for r, cmd in zip(response, commands):
|
|
632
|
+
if not isinstance(r, Exception):
|
|
633
|
+
if isinstance(cmd.callback, AsyncPreProcessingCallback):
|
|
634
|
+
await cmd.callback.pre_process(self.client, r)
|
|
635
|
+
r = cmd.callback(r, version=connection.protocol_version, **cmd.execution_parameters)
|
|
636
|
+
cmd.response = asyncio.get_running_loop().create_future()
|
|
637
|
+
cmd.response.set_result(r)
|
|
638
|
+
data.append(r)
|
|
639
|
+
return tuple(data)
|
|
640
|
+
|
|
641
|
+
async def _execute_pipeline(
|
|
642
|
+
self,
|
|
643
|
+
connection: BaseConnection,
|
|
644
|
+
commands: list[PipelineCommandRequest[Any]],
|
|
645
|
+
raise_on_error: bool,
|
|
646
|
+
) -> tuple[Any, ...]:
|
|
647
|
+
# build up all commands into a single request to increase network perf
|
|
648
|
+
requests = await connection.create_requests(
|
|
649
|
+
[
|
|
650
|
+
CommandInvocation(
|
|
651
|
+
cmd.name,
|
|
652
|
+
cmd.arguments,
|
|
653
|
+
(
|
|
654
|
+
bool(cmd.execution_parameters.get("decode"))
|
|
655
|
+
if cmd.execution_parameters.get("decode")
|
|
656
|
+
else None
|
|
657
|
+
),
|
|
658
|
+
None,
|
|
659
|
+
)
|
|
660
|
+
for cmd in commands
|
|
661
|
+
],
|
|
662
|
+
timeout=self.timeout,
|
|
663
|
+
)
|
|
664
|
+
for i, cmd in enumerate(commands):
|
|
665
|
+
cmd.response = requests[i]
|
|
666
|
+
|
|
667
|
+
response: list[Any] = []
|
|
668
|
+
for cmd in commands:
|
|
669
|
+
try:
|
|
670
|
+
res = await cmd.response if cmd.response else None
|
|
671
|
+
if isinstance(cmd.callback, AsyncPreProcessingCallback):
|
|
672
|
+
await cmd.callback.pre_process(self.client, res, **cmd.execution_parameters)
|
|
673
|
+
resp = cmd.callback(
|
|
674
|
+
res,
|
|
675
|
+
version=connection.protocol_version,
|
|
676
|
+
**cmd.execution_parameters,
|
|
677
|
+
)
|
|
678
|
+
cmd.response = asyncio.get_event_loop().create_future()
|
|
679
|
+
cmd.response.set_result(resp)
|
|
680
|
+
response.append(resp)
|
|
681
|
+
except ResponseError as re:
|
|
682
|
+
cmd.response = asyncio.get_event_loop().create_future()
|
|
683
|
+
cmd.response.set_exception(re)
|
|
684
|
+
response.append(sys.exc_info()[1])
|
|
685
|
+
if raise_on_error:
|
|
686
|
+
self.raise_first_error(commands, response)
|
|
687
|
+
|
|
688
|
+
return tuple(response)
|
|
689
|
+
|
|
690
|
+
def raise_first_error(
|
|
691
|
+
self, commands: list[PipelineCommandRequest[Any]], response: ResponseType
|
|
692
|
+
) -> None:
|
|
693
|
+
assert isinstance(response, list)
|
|
694
|
+
for i, r in enumerate(response):
|
|
695
|
+
if isinstance(r, RedisError):
|
|
696
|
+
self.annotate_exception(r, i + 1, commands[i].name, commands[i].arguments)
|
|
697
|
+
raise r
|
|
698
|
+
|
|
699
|
+
def annotate_exception(
|
|
700
|
+
self,
|
|
701
|
+
exception: RedisError | None,
|
|
702
|
+
number: int,
|
|
703
|
+
command: bytes,
|
|
704
|
+
args: Iterable[RedisValueT],
|
|
705
|
+
) -> None:
|
|
706
|
+
if exception:
|
|
707
|
+
cmd = command.decode("latin-1")
|
|
708
|
+
args = " ".join(map(str, args))
|
|
709
|
+
msg = f"Command # {number} ({cmd} {args}) of pipeline caused error: {str(exception.args[0])}"
|
|
710
|
+
exception.args = (msg,) + exception.args[1:]
|
|
711
|
+
|
|
712
|
+
async def load_scripts(self) -> None:
|
|
713
|
+
# make sure all scripts that are about to be run on this pipeline exist
|
|
714
|
+
scripts = list(self.scripts)
|
|
715
|
+
immediate = self.immediate_execute_command
|
|
716
|
+
shas = [s.sha for s in scripts]
|
|
717
|
+
# we can't use the normal script_* methods because they would just
|
|
718
|
+
# get buffered in the pipeline.
|
|
719
|
+
exists = await immediate(
|
|
720
|
+
RedisCommand(CommandName.SCRIPT_EXISTS, tuple(shas)), callback=BoolsCallback()
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
if not all(exists):
|
|
724
|
+
for s, exist in zip(scripts, exists):
|
|
725
|
+
if not exist:
|
|
726
|
+
s.sha = await immediate(
|
|
727
|
+
RedisCommand(CommandName.SCRIPT_LOAD, (s.script,)),
|
|
728
|
+
callback=AnyStrCallback[AnyStr](),
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
async def execute(self, raise_on_error: bool = True) -> tuple[Any, ...]:
|
|
732
|
+
"""
|
|
733
|
+
Execute all queued commands in the pipeline. Returns a tuple of results.
|
|
734
|
+
"""
|
|
735
|
+
stack = self.command_stack
|
|
736
|
+
|
|
737
|
+
if not stack:
|
|
738
|
+
return ()
|
|
739
|
+
|
|
740
|
+
if self.scripts:
|
|
741
|
+
await self.load_scripts()
|
|
742
|
+
|
|
743
|
+
if self._transaction or self.explicit_transaction:
|
|
744
|
+
exec = self._execute_transaction
|
|
745
|
+
else:
|
|
746
|
+
exec = self._execute_pipeline
|
|
747
|
+
|
|
748
|
+
conn = self.connection
|
|
749
|
+
|
|
750
|
+
if not conn:
|
|
751
|
+
conn = await self.connection_pool.get_connection()
|
|
752
|
+
# assign to self.connection so clear() releases the connection
|
|
753
|
+
# back to the pool after we're done
|
|
754
|
+
self.connection = conn
|
|
755
|
+
|
|
756
|
+
try:
|
|
757
|
+
return await exec(conn, stack, raise_on_error)
|
|
758
|
+
except (ConnectionError, TimeoutError, CancelledError):
|
|
759
|
+
conn.disconnect()
|
|
760
|
+
|
|
761
|
+
# if we were watching a variable, the watch is no longer valid
|
|
762
|
+
# since this connection has died. raise a WatchError, which
|
|
763
|
+
# indicates the user should retry his transaction. If this is more
|
|
764
|
+
# than a temporary failure, the WATCH that the user next issues
|
|
765
|
+
# will fail, propegating the real ConnectionError
|
|
766
|
+
|
|
767
|
+
if self.watching:
|
|
768
|
+
raise WatchError("A ConnectionError occured on while watching one or more keys")
|
|
769
|
+
# otherwise, it's safe to retry since the transaction isn't
|
|
770
|
+
# predicated on any state
|
|
771
|
+
|
|
772
|
+
return await exec(conn, stack, raise_on_error)
|
|
773
|
+
finally:
|
|
774
|
+
await self.clear()
|
|
775
|
+
|
|
776
|
+
def watch(self, *keys: KeyT) -> CommandRequest[bool]:
|
|
777
|
+
"""
|
|
778
|
+
Watch the given keys for changes. Switches to immediate execution mode
|
|
779
|
+
until :meth:`multi` is called.
|
|
780
|
+
"""
|
|
781
|
+
if self.explicit_transaction:
|
|
782
|
+
raise RedisError("Cannot issue a WATCH after a MULTI")
|
|
783
|
+
|
|
784
|
+
return self.create_request(CommandName.WATCH, *keys, callback=SimpleStringCallback())
|
|
785
|
+
|
|
786
|
+
def unwatch(self) -> CommandRequest[bool]:
|
|
787
|
+
"""
|
|
788
|
+
Remove all key watches and return to buffered mode.
|
|
789
|
+
"""
|
|
790
|
+
return self.create_request(CommandName.UNWATCH, callback=SimpleStringCallback())
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
class ClusterPipeline(Client[AnyStr], metaclass=ClusterPipelineMeta):
|
|
794
|
+
"""
|
|
795
|
+
Pipeline for batching commands to a Redis Cluster.
|
|
796
|
+
Handles routing, transactions, and error management across nodes.
|
|
797
|
+
|
|
798
|
+
.. warning:: Unlike :class:`Pipeline`, :paramref:`transaction` is ``False`` by
|
|
799
|
+
default as there is limited support for transactions in redis cluster
|
|
800
|
+
(only keys in the same slot can be part of a transaction).
|
|
801
|
+
"""
|
|
802
|
+
|
|
803
|
+
client: RedisCluster[AnyStr]
|
|
804
|
+
connection_pool: ClusterConnectionPool
|
|
805
|
+
command_stack: list[ClusterPipelineCommandRequest[Any]]
|
|
806
|
+
|
|
807
|
+
RESULT_CALLBACKS: dict[str, Callable[..., Any]] = {}
|
|
808
|
+
NODES_FLAGS: dict[str, NodeFlag] = {}
|
|
809
|
+
|
|
810
|
+
def __init__(
|
|
811
|
+
self,
|
|
812
|
+
client: RedisCluster[AnyStr],
|
|
813
|
+
transaction: bool | None = False,
|
|
814
|
+
watches: Parameters[KeyT] | None = None,
|
|
815
|
+
timeout: float | None = None,
|
|
816
|
+
) -> None:
|
|
817
|
+
self.command_stack = []
|
|
818
|
+
self.refresh_table_asap = False
|
|
819
|
+
self.client = client
|
|
820
|
+
self.connection_pool = client.connection_pool
|
|
821
|
+
self.result_callbacks = client.result_callbacks
|
|
822
|
+
self._transaction = transaction
|
|
823
|
+
self._watched_node: ManagedNode | None = None
|
|
824
|
+
self._watched_connection: ClusterConnection | None = None
|
|
825
|
+
self.watches: Parameters[KeyT] | None = watches or None
|
|
826
|
+
self.watching = False
|
|
827
|
+
self.explicit_transaction = False
|
|
828
|
+
self.cache = None
|
|
829
|
+
self.timeout = timeout
|
|
830
|
+
self.type_adapter = client.type_adapter
|
|
831
|
+
|
|
832
|
+
def create_request(
|
|
833
|
+
self,
|
|
834
|
+
name: bytes,
|
|
835
|
+
*arguments: ValueT,
|
|
836
|
+
callback: Callable[..., T_co],
|
|
837
|
+
execution_parameters: ExecutionParameters | None = None,
|
|
838
|
+
) -> CommandRequest[T_co]:
|
|
839
|
+
"""
|
|
840
|
+
:meta private:
|
|
841
|
+
"""
|
|
842
|
+
return ClusterPipelineCommandRequest(
|
|
843
|
+
self, name, *arguments, callback=callback, execution_parameters=execution_parameters
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
def watch(self, *keys: KeyT) -> CommandRequest[bool]:
|
|
847
|
+
"""
|
|
848
|
+
Watch the given keys for changes. Switches to immediate execution mode
|
|
849
|
+
until :meth:`multi` is called.
|
|
850
|
+
"""
|
|
851
|
+
if self.explicit_transaction:
|
|
852
|
+
raise RedisError("Cannot issue a WATCH after a MULTI")
|
|
853
|
+
|
|
854
|
+
return self.create_request(CommandName.WATCH, *keys, callback=SimpleStringCallback())
|
|
855
|
+
|
|
856
|
+
async def unwatch(self) -> bool:
|
|
857
|
+
"""
|
|
858
|
+
Remove all key watches and return to buffered mode.
|
|
859
|
+
"""
|
|
860
|
+
if self._watched_connection:
|
|
861
|
+
try:
|
|
862
|
+
return await self._unwatch(self._watched_connection)
|
|
863
|
+
finally:
|
|
864
|
+
if self._watched_connection:
|
|
865
|
+
self.connection_pool.release(self._watched_connection)
|
|
866
|
+
self.watching = False
|
|
867
|
+
self._watched_node = None
|
|
868
|
+
self._watched_connection = None
|
|
869
|
+
return True
|
|
870
|
+
|
|
871
|
+
def __del__(self) -> None:
|
|
872
|
+
if self._watched_connection:
|
|
873
|
+
self.connection_pool.release(self._watched_connection)
|
|
874
|
+
|
|
875
|
+
def __len__(self) -> int:
|
|
876
|
+
return len(self.command_stack)
|
|
877
|
+
|
|
878
|
+
def __bool__(self) -> bool:
|
|
879
|
+
return True
|
|
880
|
+
|
|
881
|
+
def __await__(self) -> Generator[None, None, Self]:
|
|
882
|
+
yield
|
|
883
|
+
return self
|
|
884
|
+
|
|
885
|
+
async def __aenter__(self) -> ClusterPipeline[AnyStr]:
|
|
886
|
+
return self
|
|
887
|
+
|
|
888
|
+
async def __aexit__(
|
|
889
|
+
self,
|
|
890
|
+
exc_type: type[BaseException] | None,
|
|
891
|
+
exc_value: BaseException | None,
|
|
892
|
+
traceback: TracebackType | None,
|
|
893
|
+
) -> None:
|
|
894
|
+
await self.clear()
|
|
895
|
+
|
|
896
|
+
def execute_command(
|
|
897
|
+
self,
|
|
898
|
+
command: RedisCommandP,
|
|
899
|
+
callback: Callable[..., R] = NoopCallback(),
|
|
900
|
+
**options: Unpack[ExecutionParameters],
|
|
901
|
+
) -> Awaitable[R]:
|
|
902
|
+
raise NotImplementedError
|
|
903
|
+
|
|
904
|
+
def pipeline_execute_command(
|
|
905
|
+
self,
|
|
906
|
+
command: ClusterPipelineCommandRequest[Any],
|
|
907
|
+
) -> None:
|
|
908
|
+
command.position = len(self.command_stack)
|
|
909
|
+
self.command_stack.append(command)
|
|
910
|
+
|
|
911
|
+
def raise_first_error(self) -> None:
|
|
912
|
+
for c in self.command_stack:
|
|
913
|
+
r = c.result
|
|
914
|
+
|
|
915
|
+
if isinstance(r, RedisError):
|
|
916
|
+
self.annotate_exception(r, c.position + 1, c.name, c.arguments)
|
|
917
|
+
raise r
|
|
918
|
+
|
|
919
|
+
def annotate_exception(
|
|
920
|
+
self,
|
|
921
|
+
exception: RedisError | None,
|
|
922
|
+
number: int,
|
|
923
|
+
command: bytes,
|
|
924
|
+
args: Iterable[RedisValueT],
|
|
925
|
+
) -> None:
|
|
926
|
+
if exception:
|
|
927
|
+
cmd = command.decode("latin-1")
|
|
928
|
+
args = " ".join(str(x) for x in args)
|
|
929
|
+
msg = f"Command # {number} ({cmd} {args}) of pipeline caused error: {exception.args[0]}"
|
|
930
|
+
exception.args = (msg,) + exception.args[1:]
|
|
931
|
+
|
|
932
|
+
async def execute(self, raise_on_error: bool = True) -> tuple[object, ...]:
|
|
933
|
+
"""
|
|
934
|
+
Execute all queued commands in the cluster pipeline. Returns a tuple of results.
|
|
935
|
+
"""
|
|
936
|
+
await self.connection_pool.initialize()
|
|
937
|
+
|
|
938
|
+
if not self.command_stack:
|
|
939
|
+
return ()
|
|
940
|
+
|
|
941
|
+
if self._transaction or self.explicit_transaction:
|
|
942
|
+
execute = self.send_cluster_transaction
|
|
943
|
+
else:
|
|
944
|
+
execute = self.send_cluster_commands
|
|
945
|
+
try:
|
|
946
|
+
return await execute(raise_on_error)
|
|
947
|
+
finally:
|
|
948
|
+
await self.clear()
|
|
949
|
+
|
|
950
|
+
async def clear(self) -> None:
|
|
951
|
+
"""
|
|
952
|
+
Clear the pipeline, reset state, and release any held connections.
|
|
953
|
+
"""
|
|
954
|
+
self.command_stack = []
|
|
955
|
+
|
|
956
|
+
self.scripts: set[Script[AnyStr]] = set()
|
|
957
|
+
# clean up the other instance attributes
|
|
958
|
+
self.watching = False
|
|
959
|
+
self.explicit_transaction = False
|
|
960
|
+
self._watched_node = None
|
|
961
|
+
if self._watched_connection:
|
|
962
|
+
self.connection_pool.release(self._watched_connection)
|
|
963
|
+
self._watched_connection = None
|
|
964
|
+
|
|
965
|
+
#: :meta private:
|
|
966
|
+
reset_pipeline = clear
|
|
967
|
+
|
|
968
|
+
@deprecated(
|
|
969
|
+
"The reset method in pipelines clashes with the redis ``RESET`` command. Use :meth:`clear` instead",
|
|
970
|
+
"5.0.0",
|
|
971
|
+
)
|
|
972
|
+
def reset(self) -> CommandRequest[None]:
|
|
973
|
+
"""
|
|
974
|
+
Empties the pipeline and resets / returns the connection
|
|
975
|
+
back to the pool
|
|
976
|
+
|
|
977
|
+
:meta private:
|
|
978
|
+
"""
|
|
979
|
+
return self.clear() # type: ignore
|
|
980
|
+
|
|
981
|
+
@retryable(policy=ConstantRetryPolicy((ClusterDownError,), 3, 0.1))
|
|
982
|
+
async def send_cluster_transaction(self, raise_on_error: bool = True) -> tuple[object, ...]:
|
|
983
|
+
"""
|
|
984
|
+
:meta private:
|
|
985
|
+
"""
|
|
986
|
+
attempt = sorted(self.command_stack, key=lambda x: x.position)
|
|
987
|
+
slots: set[int] = set()
|
|
988
|
+
for c in attempt:
|
|
989
|
+
slot = self._determine_slot(c.name, *c.arguments, **c.execution_parameters)
|
|
990
|
+
if slot:
|
|
991
|
+
slots.add(slot)
|
|
992
|
+
|
|
993
|
+
if len(slots) > 1:
|
|
994
|
+
raise ClusterTransactionError("Multiple slots involved in transaction")
|
|
995
|
+
if not slots:
|
|
996
|
+
raise ClusterTransactionError("No slots found for transaction")
|
|
997
|
+
node = self.connection_pool.get_node_by_slot(slots.pop())
|
|
998
|
+
|
|
999
|
+
if self._watched_node and node.name != self._watched_node.name:
|
|
1000
|
+
raise ClusterTransactionError("Multiple slots involved in transaction")
|
|
1001
|
+
|
|
1002
|
+
conn = self._watched_connection or await self.connection_pool.get_connection_by_node(node)
|
|
1003
|
+
|
|
1004
|
+
if self.watches:
|
|
1005
|
+
await self._watch(node, conn, self.watches)
|
|
1006
|
+
node_commands = NodeCommands(self.client, conn, in_transaction=True, timeout=self.timeout)
|
|
1007
|
+
node_commands.extend(attempt)
|
|
1008
|
+
self.explicit_transaction = True
|
|
1009
|
+
|
|
1010
|
+
await node_commands.write()
|
|
1011
|
+
try:
|
|
1012
|
+
await node_commands.read()
|
|
1013
|
+
except ExecAbortError:
|
|
1014
|
+
if self.explicit_transaction:
|
|
1015
|
+
request = await conn.create_request(CommandName.DISCARD)
|
|
1016
|
+
await request
|
|
1017
|
+
# If at least one watched key is modified before EXEC, the transaction aborts and EXEC returns null.
|
|
1018
|
+
|
|
1019
|
+
if node_commands.exec_cmd and await node_commands.exec_cmd is None:
|
|
1020
|
+
raise WatchError
|
|
1021
|
+
self.connection_pool.release(conn)
|
|
1022
|
+
|
|
1023
|
+
if self.watching:
|
|
1024
|
+
await self._unwatch(conn)
|
|
1025
|
+
|
|
1026
|
+
if raise_on_error:
|
|
1027
|
+
self.raise_first_error()
|
|
1028
|
+
|
|
1029
|
+
return tuple(
|
|
1030
|
+
n.result
|
|
1031
|
+
for n in node_commands.commands
|
|
1032
|
+
if n.name not in {CommandName.MULTI, CommandName.EXEC}
|
|
1033
|
+
)
|
|
1034
|
+
|
|
1035
|
+
@retryable(policy=ConstantRetryPolicy((ClusterDownError,), 3, 0.1))
|
|
1036
|
+
async def send_cluster_commands(
|
|
1037
|
+
self, raise_on_error: bool = True, allow_redirections: bool = True
|
|
1038
|
+
) -> tuple[object, ...]:
|
|
1039
|
+
"""
|
|
1040
|
+
Execute all queued commands in the cluster pipeline, handling redirections
|
|
1041
|
+
and retries as needed.
|
|
1042
|
+
|
|
1043
|
+
:meta private:
|
|
1044
|
+
"""
|
|
1045
|
+
# On first send, queue all commands. On retry, only failed ones.
|
|
1046
|
+
attempt = sorted(self.command_stack, key=lambda x: x.position)
|
|
1047
|
+
|
|
1048
|
+
# Group commands by node for efficient network usage.
|
|
1049
|
+
nodes: dict[str, NodeCommands] = {}
|
|
1050
|
+
for c in attempt:
|
|
1051
|
+
slot = self._determine_slot(c.name, *c.arguments)
|
|
1052
|
+
node = self.connection_pool.get_node_by_slot(slot)
|
|
1053
|
+
if node.name not in nodes:
|
|
1054
|
+
nodes[node.name] = NodeCommands(
|
|
1055
|
+
self.client,
|
|
1056
|
+
await self.connection_pool.get_connection_by_node(node),
|
|
1057
|
+
timeout=self.timeout,
|
|
1058
|
+
)
|
|
1059
|
+
nodes[node.name].append(c)
|
|
1060
|
+
|
|
1061
|
+
# Write to all nodes, then read from all nodes in sequence.
|
|
1062
|
+
node_commands = nodes.values()
|
|
1063
|
+
for n in node_commands:
|
|
1064
|
+
await n.write()
|
|
1065
|
+
for n in node_commands:
|
|
1066
|
+
await n.read()
|
|
1067
|
+
|
|
1068
|
+
# Release all connections back to the pool only if safe (no unread buffer).
|
|
1069
|
+
# If an error occurred, do not release to avoid buffer mismatches.
|
|
1070
|
+
for n in nodes.values():
|
|
1071
|
+
protocol_version = n.connection.protocol_version
|
|
1072
|
+
self.connection_pool.release(n.connection)
|
|
1073
|
+
|
|
1074
|
+
# Retry MOVED/ASK/connection errors one by one if allowed.
|
|
1075
|
+
attempt = sorted(
|
|
1076
|
+
(c for c in attempt if isinstance(c.result, ERRORS_ALLOW_RETRY)),
|
|
1077
|
+
key=lambda x: x.position,
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
if attempt and allow_redirections:
|
|
1081
|
+
await self.connection_pool.nodes.increment_reinitialize_counter(len(attempt))
|
|
1082
|
+
for c in attempt:
|
|
1083
|
+
try:
|
|
1084
|
+
c.result = await self.client.execute_command(
|
|
1085
|
+
RedisCommand(c.name, c.arguments), **c.execution_parameters
|
|
1086
|
+
)
|
|
1087
|
+
except RedisError as e:
|
|
1088
|
+
c.result = e
|
|
1089
|
+
|
|
1090
|
+
# Flatten results to match the original command order.
|
|
1091
|
+
response = []
|
|
1092
|
+
for c in sorted(self.command_stack, key=lambda x: x.position):
|
|
1093
|
+
r = c.result
|
|
1094
|
+
if not isinstance(c.result, RedisError):
|
|
1095
|
+
if isinstance(c.callback, AsyncPreProcessingCallback):
|
|
1096
|
+
await c.callback.pre_process(self.client, c.result)
|
|
1097
|
+
r = c.callback(c.result, version=protocol_version)
|
|
1098
|
+
response.append(r)
|
|
1099
|
+
|
|
1100
|
+
if raise_on_error:
|
|
1101
|
+
self.raise_first_error()
|
|
1102
|
+
|
|
1103
|
+
return tuple(response)
|
|
1104
|
+
|
|
1105
|
+
def _determine_slot(
|
|
1106
|
+
self, command: bytes, *args: ValueT, **options: Unpack[ExecutionParameters]
|
|
1107
|
+
) -> int:
|
|
1108
|
+
"""
|
|
1109
|
+
Determine the hash slot for the given command and arguments.
|
|
1110
|
+
"""
|
|
1111
|
+
keys: tuple[RedisValueT, ...] = cast(
|
|
1112
|
+
tuple[RedisValueT, ...], options.get("keys")
|
|
1113
|
+
) or KeySpec.extract_keys(command, *args) # type: ignore
|
|
1114
|
+
|
|
1115
|
+
if not keys:
|
|
1116
|
+
raise RedisClusterException(
|
|
1117
|
+
f"No way to dispatch {nativestr(command)} to Redis Cluster. Missing key"
|
|
1118
|
+
)
|
|
1119
|
+
slots = {hash_slot(b(key)) for key in keys}
|
|
1120
|
+
|
|
1121
|
+
if len(slots) != 1:
|
|
1122
|
+
raise ClusterCrossSlotError(command=command, keys=keys)
|
|
1123
|
+
return slots.pop()
|
|
1124
|
+
|
|
1125
|
+
def _fail_on_redirect(self, allow_redirections: bool) -> None:
|
|
1126
|
+
"""
|
|
1127
|
+
Raise if redirections are not allowed in the pipeline.
|
|
1128
|
+
"""
|
|
1129
|
+
if not allow_redirections:
|
|
1130
|
+
raise RedisClusterException("ASK & MOVED redirection not allowed in this pipeline")
|
|
1131
|
+
|
|
1132
|
+
def multi(self) -> None:
|
|
1133
|
+
"""
|
|
1134
|
+
Start a transactional block after WATCH commands. End with `execute()`.
|
|
1135
|
+
"""
|
|
1136
|
+
if self.explicit_transaction:
|
|
1137
|
+
raise RedisError("Cannot issue nested calls to MULTI")
|
|
1138
|
+
|
|
1139
|
+
if self.command_stack:
|
|
1140
|
+
raise RedisError("Commands without an initial WATCH have already been issued")
|
|
1141
|
+
self.explicit_transaction = True
|
|
1142
|
+
|
|
1143
|
+
async def immediate_execute_command(
|
|
1144
|
+
self,
|
|
1145
|
+
command: RedisCommandP,
|
|
1146
|
+
callback: Callable[..., R] = NoopCallback(),
|
|
1147
|
+
**kwargs: Unpack[ExecutionParameters],
|
|
1148
|
+
) -> R:
|
|
1149
|
+
slot = self._determine_slot(command.name, *command.arguments)
|
|
1150
|
+
node = self.connection_pool.get_node_by_slot(slot)
|
|
1151
|
+
if command.name == CommandName.WATCH:
|
|
1152
|
+
if self._watched_node and node.name != self._watched_node.name:
|
|
1153
|
+
raise ClusterTransactionError(
|
|
1154
|
+
"Cannot issue a watch on a different node in the same transaction"
|
|
1155
|
+
)
|
|
1156
|
+
else:
|
|
1157
|
+
self._watched_node = node
|
|
1158
|
+
self._watched_connection = conn = (
|
|
1159
|
+
self._watched_connection or await self.connection_pool.get_connection_by_node(node)
|
|
1160
|
+
)
|
|
1161
|
+
else:
|
|
1162
|
+
conn = await self.connection_pool.get_connection_by_node(node)
|
|
1163
|
+
|
|
1164
|
+
try:
|
|
1165
|
+
request = await conn.create_request(
|
|
1166
|
+
command.name, *command.arguments, decode=kwargs.get("decode")
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
return callback(
|
|
1170
|
+
await request,
|
|
1171
|
+
version=conn.protocol_version,
|
|
1172
|
+
)
|
|
1173
|
+
except (ConnectionError, TimeoutError):
|
|
1174
|
+
conn.disconnect()
|
|
1175
|
+
|
|
1176
|
+
try:
|
|
1177
|
+
if not self.watching:
|
|
1178
|
+
request = await conn.create_request(
|
|
1179
|
+
command.name, *command.arguments, decode=kwargs.get("decode")
|
|
1180
|
+
)
|
|
1181
|
+
return callback(await request, version=conn.protocol_version)
|
|
1182
|
+
else:
|
|
1183
|
+
raise
|
|
1184
|
+
except ConnectionError:
|
|
1185
|
+
# the retry failed so cleanup.
|
|
1186
|
+
conn.disconnect()
|
|
1187
|
+
await self.clear()
|
|
1188
|
+
raise
|
|
1189
|
+
finally:
|
|
1190
|
+
release = True
|
|
1191
|
+
if command.name in UNWATCH_COMMANDS:
|
|
1192
|
+
self.watching = False
|
|
1193
|
+
elif command.name == CommandName.WATCH:
|
|
1194
|
+
self.watching = True
|
|
1195
|
+
release = False
|
|
1196
|
+
if release:
|
|
1197
|
+
self.connection_pool.release(conn)
|
|
1198
|
+
|
|
1199
|
+
def load_scripts(self) -> None:
|
|
1200
|
+
raise RedisClusterException("method load_scripts() is not implemented")
|
|
1201
|
+
|
|
1202
|
+
async def _watch(self, node: ManagedNode, conn: BaseConnection, keys: Parameters[KeyT]) -> bool:
|
|
1203
|
+
for key in keys:
|
|
1204
|
+
slot = self._determine_slot(CommandName.WATCH, key)
|
|
1205
|
+
dist_node = self.connection_pool.get_node_by_slot(slot)
|
|
1206
|
+
|
|
1207
|
+
if node.name != dist_node.name:
|
|
1208
|
+
raise ClusterTransactionError("Keys in request don't hash to the same node")
|
|
1209
|
+
|
|
1210
|
+
if self.explicit_transaction:
|
|
1211
|
+
raise RedisError("Cannot issue a WATCH after a MULTI")
|
|
1212
|
+
request = await conn.create_request(CommandName.WATCH, *keys)
|
|
1213
|
+
|
|
1214
|
+
return SimpleStringCallback()(
|
|
1215
|
+
cast(StringT, await request),
|
|
1216
|
+
version=conn.protocol_version,
|
|
1217
|
+
)
|
|
1218
|
+
|
|
1219
|
+
async def _unwatch(self, conn: BaseConnection) -> bool:
|
|
1220
|
+
"""Unwatches all previously specified keys"""
|
|
1221
|
+
if not self.watching:
|
|
1222
|
+
return True
|
|
1223
|
+
request = await conn.create_request(CommandName.UNWATCH, decode=False)
|
|
1224
|
+
res = cast(bytes, await request)
|
|
1225
|
+
return res == b"OK"
|