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.
Files changed (100) hide show
  1. 22fe76227e35f92ab5c3__mypyc.cpython-313-darwin.so +0 -0
  2. coredis/__init__.py +42 -0
  3. coredis/_enum.py +42 -0
  4. coredis/_json.py +11 -0
  5. coredis/_packer.cpython-313-darwin.so +0 -0
  6. coredis/_packer.py +71 -0
  7. coredis/_protocols.py +50 -0
  8. coredis/_py_311_typing.py +20 -0
  9. coredis/_py_312_typing.py +17 -0
  10. coredis/_sidecar.py +114 -0
  11. coredis/_utils.cpython-313-darwin.so +0 -0
  12. coredis/_utils.py +440 -0
  13. coredis/_version.py +34 -0
  14. coredis/_version.pyi +1 -0
  15. coredis/cache.py +801 -0
  16. coredis/client/__init__.py +6 -0
  17. coredis/client/basic.py +1240 -0
  18. coredis/client/cluster.py +1265 -0
  19. coredis/commands/__init__.py +64 -0
  20. coredis/commands/_key_spec.py +517 -0
  21. coredis/commands/_utils.py +108 -0
  22. coredis/commands/_validators.py +159 -0
  23. coredis/commands/_wrappers.py +175 -0
  24. coredis/commands/bitfield.py +110 -0
  25. coredis/commands/constants.py +662 -0
  26. coredis/commands/core.py +8484 -0
  27. coredis/commands/function.py +408 -0
  28. coredis/commands/monitor.py +168 -0
  29. coredis/commands/pubsub.py +905 -0
  30. coredis/commands/request.py +108 -0
  31. coredis/commands/script.py +296 -0
  32. coredis/commands/sentinel.py +246 -0
  33. coredis/config.py +50 -0
  34. coredis/connection.py +906 -0
  35. coredis/constants.cpython-313-darwin.so +0 -0
  36. coredis/constants.py +37 -0
  37. coredis/credentials.py +45 -0
  38. coredis/exceptions.py +360 -0
  39. coredis/experimental/__init__.py +1 -0
  40. coredis/globals.py +23 -0
  41. coredis/modules/__init__.py +121 -0
  42. coredis/modules/autocomplete.py +138 -0
  43. coredis/modules/base.py +262 -0
  44. coredis/modules/filters.py +1319 -0
  45. coredis/modules/graph.py +362 -0
  46. coredis/modules/json.py +691 -0
  47. coredis/modules/response/__init__.py +0 -0
  48. coredis/modules/response/_callbacks/__init__.py +0 -0
  49. coredis/modules/response/_callbacks/autocomplete.py +42 -0
  50. coredis/modules/response/_callbacks/graph.py +237 -0
  51. coredis/modules/response/_callbacks/json.py +21 -0
  52. coredis/modules/response/_callbacks/search.py +221 -0
  53. coredis/modules/response/_callbacks/timeseries.py +158 -0
  54. coredis/modules/response/types.py +179 -0
  55. coredis/modules/search.py +1089 -0
  56. coredis/modules/timeseries.py +1139 -0
  57. coredis/parser.cpython-313-darwin.so +0 -0
  58. coredis/parser.py +344 -0
  59. coredis/pipeline.py +1225 -0
  60. coredis/pool/__init__.py +11 -0
  61. coredis/pool/basic.py +453 -0
  62. coredis/pool/cluster.py +517 -0
  63. coredis/pool/nodemanager.py +340 -0
  64. coredis/py.typed +0 -0
  65. coredis/recipes/__init__.py +0 -0
  66. coredis/recipes/credentials/__init__.py +5 -0
  67. coredis/recipes/credentials/iam_provider.py +63 -0
  68. coredis/recipes/locks/__init__.py +5 -0
  69. coredis/recipes/locks/extend.lua +17 -0
  70. coredis/recipes/locks/lua_lock.py +281 -0
  71. coredis/recipes/locks/release.lua +10 -0
  72. coredis/response/__init__.py +5 -0
  73. coredis/response/_callbacks/__init__.py +538 -0
  74. coredis/response/_callbacks/acl.py +32 -0
  75. coredis/response/_callbacks/cluster.py +183 -0
  76. coredis/response/_callbacks/command.py +86 -0
  77. coredis/response/_callbacks/connection.py +31 -0
  78. coredis/response/_callbacks/geo.py +58 -0
  79. coredis/response/_callbacks/hash.py +85 -0
  80. coredis/response/_callbacks/keys.py +59 -0
  81. coredis/response/_callbacks/module.py +33 -0
  82. coredis/response/_callbacks/script.py +85 -0
  83. coredis/response/_callbacks/sentinel.py +179 -0
  84. coredis/response/_callbacks/server.py +241 -0
  85. coredis/response/_callbacks/sets.py +44 -0
  86. coredis/response/_callbacks/sorted_set.py +204 -0
  87. coredis/response/_callbacks/streams.py +185 -0
  88. coredis/response/_callbacks/strings.py +70 -0
  89. coredis/response/_callbacks/vector_sets.py +159 -0
  90. coredis/response/_utils.py +33 -0
  91. coredis/response/types.py +416 -0
  92. coredis/retry.py +233 -0
  93. coredis/sentinel.py +477 -0
  94. coredis/stream.py +369 -0
  95. coredis/tokens.py +2286 -0
  96. coredis/typing.py +593 -0
  97. coredis-5.5.0.dist-info/METADATA +211 -0
  98. coredis-5.5.0.dist-info/RECORD +100 -0
  99. coredis-5.5.0.dist-info/WHEEL +6 -0
  100. 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"