1import asyncio
2import collections
3import logging
4import random
5import socket
6import threading
7import time
8import warnings
9import weakref
10from abc import ABC, abstractmethod
11from collections import defaultdict
12from copy import copy
13from itertools import chain
14from types import MethodType
15from typing import (
16 TYPE_CHECKING,
17 Any,
18 Callable,
19 Coroutine,
20 Deque,
21 Dict,
22 Generator,
23 List,
24 Literal,
25 Mapping,
26 Optional,
27 Set,
28 Tuple,
29 Type,
30 TypeVar,
31 Union,
32)
33
34if TYPE_CHECKING:
35 from redis.asyncio.keyspace_notifications import (
36 AsyncClusterKeyspaceNotifications,
37 )
38
39from redis._defaults import (
40 DEFAULT_RETRY_BASE,
41 DEFAULT_RETRY_CAP,
42 DEFAULT_RETRY_COUNT,
43 DEFAULT_SOCKET_CONNECT_TIMEOUT,
44 DEFAULT_SOCKET_READ_SIZE,
45 DEFAULT_SOCKET_TIMEOUT,
46)
47from redis._parsers import AsyncCommandsParser, Encoder
48from redis._parsers.commands import CommandPolicies, RequestPolicy, ResponsePolicy
49from redis._parsers.helpers import get_response_callbacks
50from redis.asyncio.client import PubSub, ResponseCallbackT
51from redis.asyncio.connection import (
52 AbstractConnection,
53 Connection,
54 ConnectionPoolInterface,
55 SSLConnection,
56 parse_url,
57)
58from redis.asyncio.lock import Lock
59from redis.asyncio.observability.recorder import (
60 record_error_count,
61 record_operation_duration,
62)
63from redis.asyncio.retry import Retry
64from redis.auth.token import TokenInterface
65from redis.backoff import ExponentialWithJitterBackoff, NoBackoff
66from redis.client import EMPTY_RESPONSE, NEVER_DECODE, AbstractRedis
67from redis.cluster import (
68 PIPELINE_BLOCKED_COMMANDS,
69 PRIMARY,
70 REPLICA,
71 SLOT_ID,
72 AbstractRedisCluster,
73 LoadBalancer,
74 LoadBalancingStrategy,
75 block_pipeline_command,
76 get_node_name,
77 parse_cluster_shards,
78 parse_cluster_shards_unified,
79 parse_cluster_shards_with_str_keys,
80 parse_cluster_slots,
81)
82from redis.commands import READ_COMMANDS, AsyncRedisClusterCommands
83from redis.commands.helpers import list_or_args, parse_pubsub_subscriptions
84from redis.commands.policies import AsyncPolicyResolver, AsyncStaticPolicyResolver
85from redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot
86from redis.credentials import CredentialProvider
87from redis.driver_info import DriverInfo, resolve_driver_info
88from redis.event import (
89 AfterAsyncClusterInstantiationEvent,
90 AsyncAfterSlotsCacheRefreshEvent,
91 AsyncEventListenerInterface,
92 EventDispatcher,
93)
94from redis.exceptions import (
95 AskError,
96 BusyLoadingError,
97 ClusterDownError,
98 ClusterError,
99 ConnectionError,
100 CrossSlotTransactionError,
101 DataError,
102 ExecAbortError,
103 InvalidPipelineStack,
104 MaxConnectionsError,
105 MovedError,
106 RedisClusterException,
107 RedisError,
108 ResponseError,
109 SlotNotCoveredError,
110 TimeoutError,
111 TryAgainError,
112 WatchError,
113)
114from redis.typing import (
115 AnyKeyT,
116 ChannelT,
117 EncodableT,
118 KeyT,
119 PubSubHandler,
120 Subscription,
121)
122from redis.utils import (
123 SENTINEL,
124 SSL_AVAILABLE,
125 deprecated_args,
126 deprecated_function,
127 safe_str,
128 str_if_bytes,
129 truncate_text,
130)
131
132if SSL_AVAILABLE:
133 from ssl import TLSVersion, VerifyFlags, VerifyMode
134else:
135 TLSVersion = None
136 VerifyMode = None
137 VerifyFlags = None
138
139logger = logging.getLogger(__name__)
140
141TargetNodesT = TypeVar(
142 "TargetNodesT", str, "ClusterNode", List["ClusterNode"], Dict[Any, "ClusterNode"]
143)
144
145
146class RedisCluster(AbstractRedis, AbstractRedisCluster, AsyncRedisClusterCommands):
147 """
148 Create a new RedisCluster client.
149
150 Pass one of parameters:
151
152 - `host` & `port`
153 - `startup_nodes`
154
155 | Use ``await`` :meth:`initialize` to find cluster nodes & create connections.
156 | Use ``await`` :meth:`close` to disconnect connections & close client.
157
158 Many commands support the target_nodes kwarg. It can be one of the
159 :attr:`NODE_FLAGS`:
160
161 - :attr:`PRIMARIES`
162 - :attr:`REPLICAS`
163 - :attr:`ALL_NODES`
164 - :attr:`RANDOM`
165 - :attr:`DEFAULT_NODE`
166
167 Note: This client is not thread/process/fork safe.
168
169 :param host:
170 | Can be used to point to a startup node
171 :param port:
172 | Port used if **host** is provided
173 :param startup_nodes:
174 | :class:`~.ClusterNode` to used as a startup node
175 :param require_full_coverage:
176 | When set to ``False``: the client will not require a full coverage of
177 the slots. However, if not all slots are covered, and at least one node
178 has ``cluster-require-full-coverage`` set to ``yes``, the server will throw
179 a :class:`~.ClusterDownError` for some key-based commands.
180 | When set to ``True``: all slots must be covered to construct the cluster
181 client. If not all slots are covered, :class:`~.RedisClusterException` will be
182 thrown.
183 | See:
184 https://redis.io/docs/manual/scaling/#redis-cluster-configuration-parameters
185 :param read_from_replicas:
186 | @deprecated - please use load_balancing_strategy instead
187 | Enable read from replicas in READONLY mode.
188 When set to true, read commands will be assigned between the primary and
189 its replications in a Round-Robin manner.
190 The data read from replicas is eventually consistent with the data in primary nodes.
191 :param load_balancing_strategy:
192 | Enable read from replicas in READONLY mode and defines the load balancing
193 strategy that will be used for cluster node selection.
194 The data read from replicas is eventually consistent with the data in primary nodes.
195 :param dynamic_startup_nodes:
196 | Set the RedisCluster's startup nodes to all the discovered nodes.
197 If true (default value), the cluster's discovered nodes will be used to
198 determine the cluster nodes-slots mapping in the next topology refresh.
199 It will remove the initial passed startup nodes if their endpoints aren't
200 listed in the CLUSTER SLOTS output.
201 If you use dynamic DNS endpoints for startup nodes but CLUSTER SLOTS lists
202 specific IP addresses, it is best to set it to false.
203 :param reinitialize_steps:
204 | Specifies the number of MOVED errors that need to occur before reinitializing
205 the whole cluster topology. If a MOVED error occurs and the cluster does not
206 need to be reinitialized on this current error handling, only the MOVED slot
207 will be patched with the redirected node.
208 To reinitialize the cluster on every MOVED error, set reinitialize_steps to 1.
209 To avoid reinitializing the cluster on moved errors, set reinitialize_steps to
210 0.
211 :param cluster_error_retry_attempts:
212 | @deprecated - Please configure the 'retry' object instead
213 In case 'retry' object is set - this argument is ignored!
214
215 Number of times to retry before raising an error when :class:`~.TimeoutError`,
216 :class:`~.ConnectionError`, :class:`~.SlotNotCoveredError`
217 or :class:`~.ClusterDownError` are encountered
218 :param retry:
219 | A retry object that defines the retry strategy and the number of
220 retries for the cluster client.
221 In current implementation for the cluster client (starting form redis-py version 6.0.0)
222 the retry object is not yet fully utilized, instead it is used just to determine
223 the number of retries for the cluster client.
224 In the future releases the retry object will be used to handle the cluster client retries!
225 :param max_connections:
226 | Maximum number of connections per node. If there are no free connections & the
227 maximum number of connections are already created, a
228 :class:`~.MaxConnectionsError` is raised.
229 :param socket_keepalive:
230 | If ``True``, TCP keepalive is enabled for TCP socket connections.
231 :param socket_keepalive_options:
232 | Mapping of TCP keepalive socket option constants to values, for
233 example ``{socket.TCP_KEEPIDLE: 30}``. If left unspecified, redis-py
234 uses TCP keepalive defaults when ``socket_keepalive`` is enabled:
235 idle 30 seconds, interval 5 seconds, and 3 probes.
236 Platform-specific options that are not available are skipped.
237 Pass ``None`` or ``{}`` to avoid setting additional TCP keepalive
238 options.
239 :param address_remap:
240 | An optional callable which, when provided with an internal network
241 address of a node, e.g. a `(host, port)` tuple, will return the address
242 where the node is reachable. This can be used to map the addresses at
243 which the nodes _think_ they are, to addresses at which a client may
244 reach them, such as when they sit behind a proxy.
245
246 | Rest of the arguments will be passed to the
247 :class:`~redis.asyncio.connection.Connection` instances when created
248
249 :raises RedisClusterException:
250 if any arguments are invalid or unknown. Eg:
251
252 - `db` != 0 or None
253 - `path` argument for unix socket connection
254 - none of the `host`/`port` & `startup_nodes` were provided
255
256 """
257
258 @classmethod
259 def from_url(cls, url: str, **kwargs: Any) -> "RedisCluster":
260 """
261 Return a Redis client object configured from the given URL.
262
263 For example::
264
265 redis://[[username]:[password]]@localhost:6379/0
266 rediss://[[username]:[password]]@localhost:6379/0
267
268 Three URL schemes are supported:
269
270 - `redis://` creates a TCP socket connection. See more at:
271 <https://www.iana.org/assignments/uri-schemes/prov/redis>
272 - `rediss://` creates a SSL wrapped TCP socket connection. See more at:
273 <https://www.iana.org/assignments/uri-schemes/prov/rediss>
274
275 The username, password, hostname, path and all querystring values are passed
276 through ``urllib.parse.unquote`` in order to replace any percent-encoded values
277 with their corresponding characters.
278
279 All querystring options are cast to their appropriate Python types. Boolean
280 arguments can be specified with string values "True"/"False" or "Yes"/"No".
281 Values that cannot be properly cast cause a ``ValueError`` to be raised. Once
282 parsed, the querystring arguments and keyword arguments are passed to
283 :class:`~redis.asyncio.connection.Connection` when created.
284 In the case of conflicting arguments, querystring arguments are used.
285 """
286 kwargs.update(parse_url(url))
287 if kwargs.pop("connection_class", None) is SSLConnection:
288 kwargs["ssl"] = True
289 return cls(**kwargs)
290
291 # Type discrimination marker for @overload self-type pattern
292 _is_async_client: Literal[True] = True
293
294 __slots__ = (
295 "_initialize",
296 "_lock",
297 "retry",
298 "command_flags",
299 "commands_parser",
300 "connection_kwargs",
301 "encoder",
302 "node_flags",
303 "nodes_manager",
304 "read_from_replicas",
305 "reinitialize_counter",
306 "reinitialize_steps",
307 "response_callbacks",
308 "result_callbacks",
309 )
310
311 @deprecated_args(
312 args_to_warn=["read_from_replicas"],
313 reason="Please configure the 'load_balancing_strategy' instead",
314 version="5.3.0",
315 )
316 @deprecated_args(
317 args_to_warn=[
318 "cluster_error_retry_attempts",
319 ],
320 reason="Please configure the 'retry' object instead",
321 version="6.0.0",
322 )
323 @deprecated_args(
324 args_to_warn=["lib_name", "lib_version"],
325 reason="Use 'driver_info' parameter instead. "
326 "lib_name and lib_version will be removed in a future version.",
327 )
328 def __init__(
329 self,
330 host: str | None = None,
331 port: str | int = 6379,
332 # Cluster related kwargs
333 startup_nodes: List["ClusterNode"] | None = None,
334 require_full_coverage: bool = True,
335 read_from_replicas: bool = False,
336 load_balancing_strategy: LoadBalancingStrategy | None = None,
337 dynamic_startup_nodes: bool = True,
338 reinitialize_steps: int = 5,
339 cluster_error_retry_attempts: int = DEFAULT_RETRY_COUNT,
340 max_connections: int = 100,
341 retry: Retry | None = None,
342 retry_on_error: List[Type[Exception]] | None = None,
343 # Client related kwargs
344 db: str | int = 0,
345 path: str | None = None,
346 credential_provider: CredentialProvider | None = None,
347 username: str | None = None,
348 password: str | None = None,
349 client_name: str | None = None,
350 lib_name: str | object | None = SENTINEL,
351 lib_version: str | object | None = SENTINEL,
352 driver_info: DriverInfo | object | None = SENTINEL,
353 # Encoding related kwargs
354 encoding: str = "utf-8",
355 encoding_errors: str = "strict",
356 decode_responses: bool = False,
357 # Connection related kwargs
358 health_check_interval: float = 0,
359 socket_timeout: float | None = DEFAULT_SOCKET_TIMEOUT,
360 socket_connect_timeout: float | None = DEFAULT_SOCKET_CONNECT_TIMEOUT,
361 socket_read_size: int = DEFAULT_SOCKET_READ_SIZE,
362 socket_keepalive: bool = True,
363 socket_keepalive_options: Mapping[int, int | bytes] | object | None = SENTINEL,
364 # SSL related kwargs
365 ssl: bool = False,
366 ssl_ca_certs: str | None = None,
367 ssl_ca_data: str | None = None,
368 ssl_cert_reqs: "str | VerifyMode" = "required",
369 ssl_include_verify_flags: List["VerifyFlags"] | None = None,
370 ssl_exclude_verify_flags: List["VerifyFlags"] | None = None,
371 ssl_certfile: str | None = None,
372 ssl_check_hostname: bool = True,
373 ssl_keyfile: str | None = None,
374 ssl_min_version: "TLSVersion | None" = None,
375 ssl_ciphers: str | None = None,
376 protocol: int | None = None,
377 legacy_responses: bool = True,
378 address_remap: Callable[[Tuple[str, int]], Tuple[str, int]] | None = None,
379 event_dispatcher: EventDispatcher | None = None,
380 policy_resolver: AsyncPolicyResolver = AsyncStaticPolicyResolver(),
381 ) -> None:
382 if db:
383 raise RedisClusterException(
384 "Argument 'db' must be 0 or None in cluster mode"
385 )
386
387 if path:
388 raise RedisClusterException(
389 "Unix domain socket is not supported in cluster mode"
390 )
391
392 if (not host or not port) and not startup_nodes:
393 raise RedisClusterException(
394 "RedisCluster requires at least one node to discover the cluster.\n"
395 "Please provide one of the following or use RedisCluster.from_url:\n"
396 ' - host and port: RedisCluster(host="localhost", port=6379)\n'
397 " - startup_nodes: RedisCluster(startup_nodes=["
398 'ClusterNode("localhost", 6379), ClusterNode("localhost", 6380)])'
399 )
400
401 computed_driver_info = resolve_driver_info(driver_info, lib_name, lib_version)
402
403 kwargs: Dict[str, Any] = {
404 "max_connections": max_connections,
405 "connection_class": Connection,
406 # Client related kwargs
407 "credential_provider": credential_provider,
408 "username": username,
409 "password": password,
410 "client_name": client_name,
411 "driver_info": computed_driver_info,
412 # Encoding related kwargs
413 "encoding": encoding,
414 "encoding_errors": encoding_errors,
415 "decode_responses": decode_responses,
416 # Connection related kwargs
417 "health_check_interval": health_check_interval,
418 "socket_connect_timeout": socket_connect_timeout,
419 "socket_keepalive": socket_keepalive,
420 "socket_keepalive_options": socket_keepalive_options,
421 "socket_read_size": socket_read_size,
422 "socket_timeout": socket_timeout,
423 "protocol": protocol,
424 "legacy_responses": legacy_responses,
425 }
426
427 if ssl:
428 # SSL related kwargs
429 kwargs.update(
430 {
431 "connection_class": SSLConnection,
432 "ssl_ca_certs": ssl_ca_certs,
433 "ssl_ca_data": ssl_ca_data,
434 "ssl_cert_reqs": ssl_cert_reqs,
435 "ssl_include_verify_flags": ssl_include_verify_flags,
436 "ssl_exclude_verify_flags": ssl_exclude_verify_flags,
437 "ssl_certfile": ssl_certfile,
438 "ssl_check_hostname": ssl_check_hostname,
439 "ssl_keyfile": ssl_keyfile,
440 "ssl_min_version": ssl_min_version,
441 "ssl_ciphers": ssl_ciphers,
442 }
443 )
444
445 if read_from_replicas or load_balancing_strategy:
446 # Call our on_connect function to configure READONLY mode
447 kwargs["redis_connect_func"] = self.on_connect
448
449 if retry:
450 self.retry = retry
451 else:
452 self.retry = Retry(
453 backoff=ExponentialWithJitterBackoff(
454 base=DEFAULT_RETRY_BASE, cap=DEFAULT_RETRY_CAP
455 ),
456 retries=cluster_error_retry_attempts,
457 )
458 if retry_on_error:
459 self.retry.update_supported_errors(retry_on_error)
460
461 kwargs["response_callbacks"] = get_response_callbacks(
462 user_protocol=kwargs.get("protocol"),
463 legacy_responses=kwargs.get("legacy_responses", True),
464 )
465 if not kwargs.get("legacy_responses", True):
466 kwargs["response_callbacks"]["CLUSTER SHARDS"] = (
467 parse_cluster_shards_unified
468 )
469 elif kwargs.get("protocol") is None:
470 kwargs["response_callbacks"]["CLUSTER SHARDS"] = (
471 parse_cluster_shards_with_str_keys
472 )
473 else:
474 kwargs["response_callbacks"]["CLUSTER SHARDS"] = parse_cluster_shards
475 self.connection_kwargs = kwargs
476
477 if startup_nodes:
478 passed_nodes = []
479 for node in startup_nodes:
480 passed_nodes.append(
481 ClusterNode(node.host, node.port, **self.connection_kwargs)
482 )
483 startup_nodes = passed_nodes
484 else:
485 startup_nodes = []
486 if host and port:
487 startup_nodes.append(ClusterNode(host, port, **self.connection_kwargs))
488
489 if event_dispatcher is None:
490 self._event_dispatcher = EventDispatcher()
491 else:
492 self._event_dispatcher = event_dispatcher
493
494 self.startup_nodes = startup_nodes
495 self.nodes_manager = NodesManager(
496 startup_nodes,
497 require_full_coverage,
498 kwargs,
499 dynamic_startup_nodes=dynamic_startup_nodes,
500 address_remap=address_remap,
501 event_dispatcher=self._event_dispatcher,
502 )
503 self.encoder = Encoder(encoding, encoding_errors, decode_responses)
504 self.read_from_replicas = read_from_replicas
505 self.load_balancing_strategy = load_balancing_strategy
506 self.reinitialize_steps = reinitialize_steps
507 self.reinitialize_counter = 0
508
509 # For backward compatibility, mapping from existing policies to new one
510 self._command_flags_mapping: dict[str, Union[RequestPolicy, ResponsePolicy]] = {
511 self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS,
512 self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS,
513 self.__class__.ALL_NODES: RequestPolicy.ALL_NODES,
514 self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS,
515 self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE,
516 SLOT_ID: RequestPolicy.DEFAULT_KEYED,
517 }
518
519 self._policies_callback_mapping: dict[
520 Union[RequestPolicy, ResponsePolicy], Callable
521 ] = {
522 RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [
523 self.get_random_primary_or_all_nodes(command_name)
524 ],
525 RequestPolicy.DEFAULT_KEYED: self.get_nodes_from_slot,
526 RequestPolicy.DEFAULT_NODE: lambda: [self.get_default_node()],
527 RequestPolicy.ALL_SHARDS: self.get_primaries,
528 RequestPolicy.ALL_NODES: self.get_nodes,
529 RequestPolicy.ALL_REPLICAS: self.get_replicas,
530 RequestPolicy.SPECIAL: self.get_special_nodes,
531 ResponsePolicy.DEFAULT_KEYLESS: lambda res: res,
532 ResponsePolicy.DEFAULT_KEYED: lambda res: res,
533 }
534
535 self._policy_resolver = policy_resolver
536 self.commands_parser = AsyncCommandsParser()
537 self._aggregate_nodes = None
538 self.node_flags = self.__class__.NODE_FLAGS.copy()
539 self.command_flags = self.__class__.COMMAND_FLAGS.copy()
540 self.response_callbacks = kwargs["response_callbacks"]
541 self.result_callbacks = self.__class__.RESULT_CALLBACKS.copy()
542 self.result_callbacks["CLUSTER SLOTS"] = (
543 lambda cmd, res, **kwargs: parse_cluster_slots(
544 list(res.values())[0], **kwargs
545 )
546 )
547
548 self._initialize = True
549 self._lock: Optional[asyncio.Lock] = None
550
551 # When used as an async context manager, we need to increment and decrement
552 # a usage counter so that we can close the connection pool when no one is
553 # using the client.
554 self._usage_counter = 0
555 self._usage_lock = asyncio.Lock()
556
557 async def initialize(
558 self,
559 additional_startup_nodes_info: Optional[List[Tuple[str, int]]] = None,
560 last_failed_node_name: Optional[str] = None,
561 ) -> "RedisCluster":
562 """Get all nodes from startup nodes & creates connections if not initialized."""
563 if self._initialize:
564 if not self._lock:
565 self._lock = asyncio.Lock()
566 async with self._lock:
567 if self._initialize:
568 try:
569 await self.nodes_manager.initialize(
570 additional_startup_nodes_info=additional_startup_nodes_info,
571 last_failed_node_name=last_failed_node_name,
572 )
573 await self.commands_parser.initialize(
574 self.nodes_manager.default_node
575 )
576 self._initialize = False
577 except BaseException:
578 await self.nodes_manager.aclose()
579 await self.nodes_manager.aclose("startup_nodes")
580 raise
581 return self
582
583 async def aclose(self) -> None:
584 """Close all connections & client if initialized."""
585 if not self._initialize:
586 if not self._lock:
587 self._lock = asyncio.Lock()
588 async with self._lock:
589 if not self._initialize:
590 self._initialize = True
591 await self.nodes_manager.aclose()
592 await self.nodes_manager.aclose("startup_nodes")
593
594 @deprecated_function(version="5.0.0", reason="Use aclose() instead", name="close")
595 async def close(self) -> None:
596 """alias for aclose() for backwards compatibility"""
597 await self.aclose()
598
599 async def __aenter__(self) -> "RedisCluster":
600 """
601 Async context manager entry. Increments a usage counter so that the
602 connection pool is only closed (via aclose()) when no context is using
603 the client.
604 """
605 await self._increment_usage()
606 try:
607 # Initialize the client (i.e. establish connection, etc.)
608 return await self.initialize()
609 except Exception:
610 # If initialization fails, decrement the counter to keep it in sync
611 await self._decrement_usage()
612 raise
613
614 async def _increment_usage(self) -> int:
615 """
616 Helper coroutine to increment the usage counter while holding the lock.
617 Returns the new value of the usage counter.
618 """
619 async with self._usage_lock:
620 self._usage_counter += 1
621 return self._usage_counter
622
623 async def _decrement_usage(self) -> int:
624 """
625 Helper coroutine to decrement the usage counter while holding the lock.
626 Returns the new value of the usage counter.
627 """
628 async with self._usage_lock:
629 self._usage_counter -= 1
630 return self._usage_counter
631
632 async def __aexit__(self, exc_type, exc_value, traceback):
633 """
634 Async context manager exit. Decrements a usage counter. If this is the
635 last exit (counter becomes zero), the client closes its connection pool.
636 """
637 current_usage = await asyncio.shield(self._decrement_usage())
638 if current_usage == 0:
639 # This was the last active context, so disconnect the pool.
640 await asyncio.shield(self.aclose())
641
642 def __await__(self) -> Generator[Any, None, "RedisCluster"]:
643 return self.initialize().__await__()
644
645 _DEL_MESSAGE = "Unclosed RedisCluster client"
646
647 def __del__(
648 self,
649 _warn: Any = warnings.warn,
650 _grl: Any = asyncio.get_running_loop,
651 ) -> None:
652 if hasattr(self, "_initialize") and not self._initialize:
653 _warn(f"{self._DEL_MESSAGE} {self!r}", ResourceWarning, source=self)
654 try:
655 context = {"client": self, "message": self._DEL_MESSAGE}
656 _grl().call_exception_handler(context)
657 except RuntimeError:
658 pass
659
660 async def on_connect(self, connection: Connection) -> None:
661 await connection.on_connect()
662
663 # Sending READONLY command to server to configure connection as
664 # readonly. Since each cluster node may change its server type due
665 # to a failover, we should establish a READONLY connection
666 # regardless of the server type. If this is a primary connection,
667 # READONLY would not affect executing write commands.
668 await connection.send_command("READONLY")
669 if str_if_bytes(await connection.read_response()) != "OK":
670 raise ConnectionError("READONLY command failed")
671
672 def get_nodes(self) -> List["ClusterNode"]:
673 """Get all nodes of the cluster."""
674 return list(self.nodes_manager.nodes_cache.values())
675
676 def get_primaries(self) -> List["ClusterNode"]:
677 """Get the primary nodes of the cluster."""
678 return self.nodes_manager.get_nodes_by_server_type(PRIMARY)
679
680 def get_replicas(self) -> List["ClusterNode"]:
681 """Get the replica nodes of the cluster."""
682 return self.nodes_manager.get_nodes_by_server_type(REPLICA)
683
684 def get_random_node(self) -> "ClusterNode":
685 """Get a random node of the cluster."""
686 return random.choice(list(self.nodes_manager.nodes_cache.values()))
687
688 def get_default_node(self) -> "ClusterNode":
689 """Get the default node of the client."""
690 return self.nodes_manager.default_node
691
692 def set_default_node(self, node: "ClusterNode") -> None:
693 """
694 Set the default node of the client.
695
696 :raises DataError: if None is passed or node does not exist in cluster.
697 """
698 if not node or not self.get_node(node_name=node.name):
699 raise DataError("The requested node does not exist in the cluster.")
700
701 self.nodes_manager.default_node = node
702
703 def get_node(
704 self,
705 host: Optional[str] = None,
706 port: Optional[int] = None,
707 node_name: Optional[str] = None,
708 ) -> Optional["ClusterNode"]:
709 """Get node by (host, port) or node_name."""
710 return self.nodes_manager.get_node(host, port, node_name)
711
712 def get_node_from_key(
713 self, key: str, replica: bool = False
714 ) -> Optional["ClusterNode"]:
715 """
716 Get the cluster node corresponding to the provided key.
717
718 :param key:
719 :param replica:
720 | Indicates if a replica should be returned
721 |
722 None will returned if no replica holds this key
723
724 :raises SlotNotCoveredError: if the key is not covered by any slot.
725 """
726 slot = self.keyslot(key)
727 slot_cache = self.nodes_manager.slots_cache.get(slot)
728 if not slot_cache:
729 raise SlotNotCoveredError(f'Slot "{slot}" is not covered by the cluster.')
730
731 if replica:
732 if len(self.nodes_manager.slots_cache[slot]) < 2:
733 return None
734 node_idx = 1
735 else:
736 node_idx = 0
737
738 return slot_cache[node_idx]
739
740 def get_random_primary_or_all_nodes(self, command_name):
741 """
742 Returns random primary or all nodes depends on READONLY mode.
743 """
744 if self.read_from_replicas and command_name in READ_COMMANDS:
745 return self.get_random_node()
746
747 return self.get_random_primary_node()
748
749 def get_random_primary_node(self) -> "ClusterNode":
750 """
751 Returns a random primary node
752 """
753 return random.choice(self.get_primaries())
754
755 async def get_nodes_from_slot(self, command: str, *args):
756 """
757 Returns a list of nodes that hold the specified keys' slots.
758 """
759 # get the node that holds the key's slot
760 return [
761 self.nodes_manager.get_node_from_slot(
762 await self._determine_slot(command, *args),
763 self.read_from_replicas and command in READ_COMMANDS,
764 self.load_balancing_strategy if command in READ_COMMANDS else None,
765 )
766 ]
767
768 def get_special_nodes(self) -> Optional[list["ClusterNode"]]:
769 """
770 Returns a list of nodes for commands with a special policy.
771 """
772 if not self._aggregate_nodes:
773 raise RedisClusterException(
774 "Cannot execute FT.CURSOR commands without FT.AGGREGATE"
775 )
776
777 return self._aggregate_nodes
778
779 def keyslot(self, key: EncodableT) -> int:
780 """
781 Find the keyslot for a given key.
782
783 See: https://redis.io/docs/manual/scaling/#redis-cluster-data-sharding
784 """
785 return key_slot(self.encoder.encode(key))
786
787 def get_encoder(self) -> Encoder:
788 """Get the encoder object of the client."""
789 return self.encoder
790
791 def get_connection_kwargs(self) -> Dict[str, Optional[Any]]:
792 """Get the kwargs passed to :class:`~redis.asyncio.connection.Connection`."""
793 return self.connection_kwargs
794
795 def set_retry(self, retry: Retry) -> None:
796 self.retry = retry
797
798 def set_response_callback(self, command: str, callback: ResponseCallbackT) -> None:
799 """Set a custom response callback."""
800 self.response_callbacks[command] = callback
801
802 async def _determine_nodes(
803 self,
804 command: str,
805 *args: Any,
806 request_policy: RequestPolicy,
807 node_flag: Optional[str] = None,
808 ) -> List["ClusterNode"]:
809 # Determine which nodes should be executed the command on.
810 # Returns a list of target nodes.
811 if not node_flag:
812 # get the nodes group for this command if it was predefined
813 node_flag = self.command_flags.get(command)
814
815 if node_flag in self._command_flags_mapping:
816 request_policy = self._command_flags_mapping[node_flag]
817
818 policy_callback = self._policies_callback_mapping[request_policy]
819
820 if request_policy == RequestPolicy.DEFAULT_KEYED:
821 nodes = await policy_callback(command, *args)
822 elif request_policy == RequestPolicy.DEFAULT_KEYLESS:
823 nodes = policy_callback(command)
824 else:
825 nodes = policy_callback()
826
827 if command.lower() == "ft.aggregate":
828 self._aggregate_nodes = nodes
829
830 return nodes
831
832 async def _determine_slot(self, command: str, *args: Any) -> int:
833 if self.command_flags.get(command) == SLOT_ID:
834 # The command contains the slot ID
835 return int(args[0])
836
837 # Get the keys in the command
838
839 # EVAL and EVALSHA are common enough that it's wasteful to go to the
840 # redis server to parse the keys. Besides, there is a bug in redis<7.0
841 # where `self._get_command_keys()` fails anyway. So, we special case
842 # EVAL/EVALSHA.
843 # - issue: https://github.com/redis/redis/issues/9493
844 # - fix: https://github.com/redis/redis/pull/9733
845 if command.upper() in ("EVAL", "EVALSHA"):
846 # command syntax: EVAL "script body" num_keys ...
847 if len(args) < 2:
848 raise RedisClusterException(
849 f"Invalid args in command: {command, *args}"
850 )
851 keys = args[2 : 2 + int(args[1])]
852 # if there are 0 keys, that means the script can be run on any node
853 # so we can just return a random slot
854 if not keys:
855 return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)
856 else:
857 keys = await self.commands_parser.get_keys(command, *args)
858 if not keys:
859 # FCALL can call a function with 0 keys, that means the function
860 # can be run on any node so we can just return a random slot
861 if command.upper() in ("FCALL", "FCALL_RO"):
862 return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS)
863 raise RedisClusterException(
864 "No way to dispatch this command to Redis Cluster. "
865 "Missing key.\nYou can execute the command by specifying "
866 f"target nodes.\nCommand: {args}"
867 )
868
869 # single key command
870 if len(keys) == 1:
871 return self.keyslot(keys[0])
872
873 # multi-key command; we need to make sure all keys are mapped to
874 # the same slot
875 slots = {self.keyslot(key) for key in keys}
876 if len(slots) != 1:
877 raise RedisClusterException(
878 f"{command} - all keys must map to the same key slot"
879 )
880
881 return slots.pop()
882
883 def _is_node_flag(self, target_nodes: Any) -> bool:
884 return isinstance(target_nodes, str) and target_nodes in self.node_flags
885
886 def _parse_target_nodes(self, target_nodes: Any) -> List["ClusterNode"]:
887 if isinstance(target_nodes, list):
888 nodes = target_nodes
889 elif isinstance(target_nodes, ClusterNode):
890 # Supports passing a single ClusterNode as a variable
891 nodes = [target_nodes]
892 elif isinstance(target_nodes, dict):
893 # Supports dictionaries of the format {node_name: node}.
894 # It enables to execute commands with multi nodes as follows:
895 # rc.cluster_save_config(rc.get_primaries())
896 nodes = list(target_nodes.values())
897 else:
898 raise TypeError(
899 "target_nodes type can be one of the following: "
900 "node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES),"
901 "ClusterNode, list<ClusterNode>, or dict<any, ClusterNode>. "
902 f"The passed type is {type(target_nodes)}"
903 )
904 return nodes
905
906 async def _record_error_metric(
907 self,
908 error: Exception,
909 connection: Union[Connection, "ClusterNode"],
910 is_internal: bool = True,
911 retry_attempts: Optional[int] = None,
912 ):
913 """
914 Records error count metric directly.
915 Accepts either a Connection or ClusterNode object.
916 """
917 await record_error_count(
918 server_address=connection.host,
919 server_port=connection.port,
920 network_peer_address=connection.host,
921 network_peer_port=connection.port,
922 error_type=error,
923 retry_attempts=retry_attempts if retry_attempts is not None else 0,
924 is_internal=is_internal,
925 )
926
927 async def _record_command_metric(
928 self,
929 command_name: str,
930 duration_seconds: float,
931 connection: Union[Connection, "ClusterNode"],
932 error: Optional[Exception] = None,
933 ):
934 """
935 Records operation duration metric directly.
936 Accepts either a Connection or ClusterNode object.
937 """
938 # Connection has db attribute, ClusterNode has connection_kwargs
939 if hasattr(connection, "db"):
940 db = connection.db
941 else:
942 db = connection.connection_kwargs.get("db", 0)
943 await record_operation_duration(
944 command_name=command_name,
945 duration_seconds=duration_seconds,
946 server_address=connection.host,
947 server_port=connection.port,
948 db_namespace=str(db) if db is not None else None,
949 error=error,
950 )
951
952 async def execute_command(self, *args: EncodableT, **kwargs: Any) -> Any:
953 """
954 Execute a raw command on the appropriate cluster node or target_nodes.
955
956 It will retry the command as specified by the retries property of
957 the :attr:`retry` & then raise an exception.
958
959 :param args:
960 | Raw command args
961 :param kwargs:
962
963 - target_nodes: :attr:`NODE_FLAGS` or :class:`~.ClusterNode`
964 or List[:class:`~.ClusterNode`] or Dict[Any, :class:`~.ClusterNode`]
965 - Rest of the kwargs are passed to the Redis connection
966
967 :raises RedisClusterException: if target_nodes is not provided & the command
968 can't be mapped to a slot
969 """
970 command = args[0]
971 target_nodes = []
972 target_nodes_specified = False
973 retry_attempts = self.retry.get_retries()
974
975 passed_targets = kwargs.pop("target_nodes", None)
976 if passed_targets and not self._is_node_flag(passed_targets):
977 target_nodes = self._parse_target_nodes(passed_targets)
978 target_nodes_specified = True
979 retry_attempts = 0
980
981 command_policies = await self._policy_resolver.resolve(args[0].lower())
982
983 if not command_policies and not target_nodes_specified:
984 command_flag = self.command_flags.get(command)
985 if not command_flag:
986 # Fallback to default policy
987 if not self.get_default_node():
988 slot = None
989 else:
990 slot = await self._determine_slot(*args)
991 if slot is None:
992 command_policies = CommandPolicies()
993 else:
994 command_policies = CommandPolicies(
995 request_policy=RequestPolicy.DEFAULT_KEYED,
996 response_policy=ResponsePolicy.DEFAULT_KEYED,
997 )
998 else:
999 if command_flag in self._command_flags_mapping:
1000 command_policies = CommandPolicies(
1001 request_policy=self._command_flags_mapping[command_flag]
1002 )
1003 else:
1004 command_policies = CommandPolicies()
1005 elif not command_policies and target_nodes_specified:
1006 command_policies = CommandPolicies()
1007
1008 # Add one for the first execution
1009 execute_attempts = 1 + retry_attempts
1010 failure_count = 0
1011
1012 # Start timing for observability
1013 start_time = time.monotonic()
1014 last_failed_node_name = None
1015
1016 for _ in range(execute_attempts):
1017 if self._initialize:
1018 await self.initialize(last_failed_node_name=last_failed_node_name)
1019 last_failed_node_name = None
1020 if (
1021 len(target_nodes) == 1
1022 and target_nodes[0] == self.get_default_node()
1023 ):
1024 # Replace the default cluster node
1025 self.replace_default_node()
1026 try:
1027 if not target_nodes_specified:
1028 # Determine the nodes to execute the command on
1029 target_nodes = await self._determine_nodes(
1030 *args,
1031 request_policy=command_policies.request_policy,
1032 node_flag=passed_targets,
1033 )
1034 if not target_nodes:
1035 raise RedisClusterException(
1036 f"No targets were found to execute {args} command on"
1037 )
1038
1039 if len(target_nodes) == 1:
1040 # Return the processed result
1041 ret = await self._execute_command(target_nodes[0], *args, **kwargs)
1042 if command in self.result_callbacks:
1043 ret = self.result_callbacks[command](
1044 command, {target_nodes[0].name: ret}, **kwargs
1045 )
1046 return self._policies_callback_mapping[
1047 command_policies.response_policy
1048 ](ret)
1049 else:
1050 keys = [node.name for node in target_nodes]
1051 values = await asyncio.gather(
1052 *(
1053 asyncio.create_task(
1054 self._execute_command(node, *args, **kwargs)
1055 )
1056 for node in target_nodes
1057 )
1058 )
1059 if command in self.result_callbacks:
1060 return self.result_callbacks[command](
1061 command, dict(zip(keys, values)), **kwargs
1062 )
1063 return self._policies_callback_mapping[
1064 command_policies.response_policy
1065 ](dict(zip(keys, values)))
1066 except Exception as e:
1067 if retry_attempts > 0 and type(e) in self.__class__.ERRORS_ALLOW_RETRY:
1068 # The nodes and slots cache were should be reinitialized.
1069 # Try again with the new cluster setup.
1070 retry_attempts -= 1
1071 failure_count += 1
1072 last_failed_node_name = getattr(e, "last_failed_node_name", None)
1073
1074 if hasattr(e, "connection"):
1075 await self._record_command_metric(
1076 command_name=command,
1077 duration_seconds=time.monotonic() - start_time,
1078 connection=e.connection,
1079 error=e,
1080 )
1081 await self._record_error_metric(
1082 error=e,
1083 connection=e.connection,
1084 retry_attempts=failure_count,
1085 )
1086 continue
1087 else:
1088 # raise the exception
1089 if hasattr(e, "connection"):
1090 await self._record_error_metric(
1091 error=e,
1092 connection=e.connection,
1093 retry_attempts=failure_count,
1094 is_internal=False,
1095 )
1096 raise e
1097
1098 async def _execute_command(
1099 self, target_node: "ClusterNode", *args: Union[KeyT, EncodableT], **kwargs: Any
1100 ) -> Any:
1101 asking = moved = False
1102 redirect_addr = None
1103 ttl = self.RedisClusterRequestTTL
1104 command = args[0]
1105 start_time = time.monotonic()
1106
1107 while ttl > 0:
1108 ttl -= 1
1109 try:
1110 if asking:
1111 target_node = self.get_node(node_name=redirect_addr)
1112 await target_node.execute_command("ASKING")
1113 asking = False
1114 elif moved:
1115 # MOVED occurred and the slots cache was updated,
1116 # refresh the target node
1117 slot = await self._determine_slot(*args)
1118 target_node = self.nodes_manager.get_node_from_slot(
1119 slot,
1120 self.read_from_replicas and args[0] in READ_COMMANDS,
1121 self.load_balancing_strategy
1122 if args[0] in READ_COMMANDS
1123 else None,
1124 )
1125 moved = False
1126
1127 response = await target_node.execute_command(*args, **kwargs)
1128 await self._record_command_metric(
1129 command_name=command,
1130 duration_seconds=time.monotonic() - start_time,
1131 connection=target_node,
1132 )
1133 return response
1134 except BusyLoadingError as e:
1135 e.connection = target_node
1136 await self._record_command_metric(
1137 command_name=command,
1138 duration_seconds=time.monotonic() - start_time,
1139 connection=target_node,
1140 error=e,
1141 )
1142 raise
1143 except MaxConnectionsError as e:
1144 # MaxConnectionsError indicates client-side resource exhaustion
1145 # (too many connections in the pool), not a node failure.
1146 # Don't treat this as a node failure - just re-raise the error
1147 # without reinitializing the cluster.
1148 e.connection = target_node
1149 await self._record_command_metric(
1150 command_name=command,
1151 duration_seconds=time.monotonic() - start_time,
1152 connection=target_node,
1153 error=e,
1154 )
1155 raise
1156 except (ConnectionError, TimeoutError) as e:
1157 # Connection retries are being handled in the node's
1158 # Retry object.
1159 # Mark active connections for reconnect and disconnect free ones
1160 # This handles connection state (like READONLY) that may be stale
1161 target_node.update_active_connections_for_reconnect()
1162 await target_node.disconnect_free_connections()
1163
1164 # Move the failed node to the end of the cached nodes list
1165 # so it's tried last during reinitialization
1166 self.nodes_manager.move_node_to_end_of_cached_nodes(target_node.name)
1167 e.last_failed_node_name = target_node.name
1168
1169 # Signal that reinitialization is needed
1170 # The retry loop will handle initialize() AND replace_default_node()
1171 self._initialize = True
1172 e.connection = target_node
1173 await self._record_command_metric(
1174 command_name=command,
1175 duration_seconds=time.monotonic() - start_time,
1176 connection=target_node,
1177 error=e,
1178 )
1179 raise
1180 except (ClusterDownError, SlotNotCoveredError) as e:
1181 # ClusterDownError can occur during a failover and to get
1182 # self-healed, we will try to reinitialize the cluster layout
1183 # and retry executing the command
1184
1185 # SlotNotCoveredError can occur when the cluster is not fully
1186 # initialized or can be temporary issue.
1187 # We will try to reinitialize the cluster topology
1188 # and retry executing the command
1189
1190 await self.aclose()
1191 await asyncio.sleep(0.25)
1192 e.connection = target_node
1193 await self._record_command_metric(
1194 command_name=command,
1195 duration_seconds=time.monotonic() - start_time,
1196 connection=target_node,
1197 error=e,
1198 )
1199 raise
1200 except MovedError as e:
1201 # First, we will try to patch the slots/nodes cache with the
1202 # redirected node output and try again. If MovedError exceeds
1203 # 'reinitialize_steps' number of times, we will force
1204 # reinitializing the tables, and then try again.
1205 # 'reinitialize_steps' counter will increase faster when
1206 # the same client object is shared between multiple threads. To
1207 # reduce the frequency you can set this variable in the
1208 # RedisCluster constructor.
1209 self.reinitialize_counter += 1
1210 if (
1211 self.reinitialize_steps
1212 and self.reinitialize_counter % self.reinitialize_steps == 0
1213 ):
1214 await self.aclose()
1215 # Reset the counter
1216 self.reinitialize_counter = 0
1217 else:
1218 await self.nodes_manager.move_slot(e)
1219 moved = True
1220 await self._record_command_metric(
1221 command_name=command,
1222 duration_seconds=time.monotonic() - start_time,
1223 connection=target_node,
1224 error=e,
1225 )
1226 await self._record_error_metric(
1227 error=e,
1228 connection=target_node,
1229 )
1230 except AskError as e:
1231 redirect_addr = get_node_name(host=e.host, port=e.port)
1232 asking = True
1233 await self._record_command_metric(
1234 command_name=command,
1235 duration_seconds=time.monotonic() - start_time,
1236 connection=target_node,
1237 error=e,
1238 )
1239 await self._record_error_metric(
1240 error=e,
1241 connection=target_node,
1242 )
1243 except TryAgainError as e:
1244 if ttl < self.RedisClusterRequestTTL / 2:
1245 await asyncio.sleep(0.05)
1246 await self._record_command_metric(
1247 command_name=command,
1248 duration_seconds=time.monotonic() - start_time,
1249 connection=target_node,
1250 error=e,
1251 )
1252 await self._record_error_metric(
1253 error=e,
1254 connection=target_node,
1255 )
1256 except ResponseError as e:
1257 e.connection = target_node
1258 await self._record_command_metric(
1259 command_name=command,
1260 duration_seconds=time.monotonic() - start_time,
1261 connection=target_node,
1262 error=e,
1263 )
1264 raise
1265 except Exception as e:
1266 e.connection = target_node
1267 await self._record_command_metric(
1268 command_name=command,
1269 duration_seconds=time.monotonic() - start_time,
1270 connection=target_node,
1271 error=e,
1272 )
1273 raise
1274
1275 e = ClusterError("TTL exhausted.")
1276 e.connection = target_node
1277 await self._record_command_metric(
1278 command_name=command,
1279 duration_seconds=time.monotonic() - start_time,
1280 connection=target_node,
1281 error=e,
1282 )
1283 raise e
1284
1285 def pipeline(
1286 self, transaction: Optional[Any] = None, shard_hint: Optional[Any] = None
1287 ) -> "ClusterPipeline":
1288 """
1289 Create & return a new :class:`~.ClusterPipeline` object.
1290
1291 Cluster implementation of pipeline does not support transaction or shard_hint.
1292
1293 :raises RedisClusterException: if transaction or shard_hint are truthy values
1294 """
1295 if shard_hint:
1296 raise RedisClusterException("shard_hint is deprecated in cluster mode")
1297
1298 return ClusterPipeline(self, transaction)
1299
1300 def pubsub(
1301 self,
1302 node: Optional["ClusterNode"] = None,
1303 host: Optional[str] = None,
1304 port: Optional[int] = None,
1305 **kwargs: Any,
1306 ) -> "ClusterPubSub":
1307 """
1308 Create and return a ClusterPubSub instance.
1309
1310 Allows passing a ClusterNode, or host&port, to get a pubsub instance
1311 connected to the specified node
1312
1313 :param node: ClusterNode to connect to
1314 :param host: Host of the node to connect to
1315 :param port: Port of the node to connect to
1316 :param kwargs: Additional keyword arguments
1317 :return: ClusterPubSub instance
1318 """
1319 return ClusterPubSub(self, node=node, host=host, port=port, **kwargs)
1320
1321 def keyspace_notifications(
1322 self,
1323 key_prefix: Union[str, bytes, None] = None,
1324 ignore_subscribe_messages: bool = True,
1325 ) -> "AsyncClusterKeyspaceNotifications":
1326 """
1327 Return an
1328 :class:`~redis.asyncio.keyspace_notifications.AsyncClusterKeyspaceNotifications`
1329 object for subscribing to keyspace and keyevent notifications across
1330 all primary nodes in the cluster.
1331
1332 Note: Keyspace notifications must be enabled on all Redis cluster nodes
1333 via the ``notify-keyspace-events`` configuration option.
1334
1335 Args:
1336 key_prefix: Optional prefix to filter and strip from keys in
1337 notifications.
1338 ignore_subscribe_messages: If True, subscribe/unsubscribe
1339 confirmations are not returned by
1340 get_message/listen.
1341 """
1342 from redis.asyncio.keyspace_notifications import (
1343 AsyncClusterKeyspaceNotifications,
1344 )
1345
1346 return AsyncClusterKeyspaceNotifications(
1347 self,
1348 key_prefix=key_prefix,
1349 ignore_subscribe_messages=ignore_subscribe_messages,
1350 )
1351
1352 def lock(
1353 self,
1354 name: KeyT,
1355 timeout: Optional[float] = None,
1356 sleep: float = 0.1,
1357 blocking: bool = True,
1358 blocking_timeout: Optional[float] = None,
1359 lock_class: Optional[Type[Lock]] = None,
1360 thread_local: bool = True,
1361 raise_on_release_error: bool = True,
1362 ) -> Lock:
1363 """
1364 Return a new Lock object using key ``name`` that mimics
1365 the behavior of threading.Lock.
1366
1367 If specified, ``timeout`` indicates a maximum life for the lock.
1368 By default, it will remain locked until release() is called.
1369
1370 ``sleep`` indicates the amount of time to sleep per loop iteration
1371 when the lock is in blocking mode and another client is currently
1372 holding the lock.
1373
1374 ``blocking`` indicates whether calling ``acquire`` should block until
1375 the lock has been acquired or to fail immediately, causing ``acquire``
1376 to return False and the lock not being acquired. Defaults to True.
1377 Note this value can be overridden by passing a ``blocking``
1378 argument to ``acquire``.
1379
1380 ``blocking_timeout`` indicates the maximum amount of time in seconds to
1381 spend trying to acquire the lock. A value of ``None`` indicates
1382 continue trying forever. ``blocking_timeout`` can be specified as a
1383 float or integer, both representing the number of seconds to wait.
1384
1385 ``lock_class`` forces the specified lock implementation. Note that as
1386 of redis-py 3.0, the only lock class we implement is ``Lock`` (which is
1387 a Lua-based lock). So, it's unlikely you'll need this parameter, unless
1388 you have created your own custom lock class.
1389
1390 ``thread_local`` indicates whether the lock token is placed in
1391 thread-local storage. By default, the token is placed in thread local
1392 storage so that a thread only sees its token, not a token set by
1393 another thread. Consider the following timeline:
1394
1395 time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds.
1396 thread-1 sets the token to "abc"
1397 time: 1, thread-2 blocks trying to acquire `my-lock` using the
1398 Lock instance.
1399 time: 5, thread-1 has not yet completed. redis expires the lock
1400 key.
1401 time: 5, thread-2 acquired `my-lock` now that it's available.
1402 thread-2 sets the token to "xyz"
1403 time: 6, thread-1 finishes its work and calls release(). if the
1404 token is *not* stored in thread local storage, then
1405 thread-1 would see the token value as "xyz" and would be
1406 able to successfully release the thread-2's lock.
1407
1408 ``raise_on_release_error`` indicates whether to raise an exception when
1409 the lock is no longer owned when exiting the context manager. By default,
1410 this is True, meaning an exception will be raised. If False, the warning
1411 will be logged and the exception will be suppressed.
1412
1413 In some use cases it's necessary to disable thread local storage. For
1414 example, if you have code where one thread acquires a lock and passes
1415 that lock instance to a worker thread to release later. If thread
1416 local storage isn't disabled in this case, the worker thread won't see
1417 the token set by the thread that acquired the lock. Our assumption
1418 is that these cases aren't common and as such default to using
1419 thread local storage."""
1420 if lock_class is None:
1421 lock_class = Lock
1422 return lock_class(
1423 self,
1424 name,
1425 timeout=timeout,
1426 sleep=sleep,
1427 blocking=blocking,
1428 blocking_timeout=blocking_timeout,
1429 thread_local=thread_local,
1430 raise_on_release_error=raise_on_release_error,
1431 )
1432
1433 async def transaction(
1434 self, func: Coroutine[None, "ClusterPipeline", Any], *watches, **kwargs
1435 ):
1436 """
1437 Convenience method for executing the callable `func` as a transaction
1438 while watching all keys specified in `watches`. The 'func' callable
1439 should expect a single argument which is a Pipeline object.
1440 """
1441 shard_hint = kwargs.pop("shard_hint", None)
1442 value_from_callable = kwargs.pop("value_from_callable", False)
1443 watch_delay = kwargs.pop("watch_delay", None)
1444 async with self.pipeline(True, shard_hint) as pipe:
1445 while True:
1446 try:
1447 if watches:
1448 await pipe.watch(*watches)
1449 func_value = await func(pipe)
1450 exec_value = await pipe.execute()
1451 return func_value if value_from_callable else exec_value
1452 except WatchError:
1453 if watch_delay is not None and watch_delay > 0:
1454 time.sleep(watch_delay)
1455 continue
1456
1457
1458class ClusterNode:
1459 """
1460 Create a new ClusterNode.
1461
1462 Each ClusterNode manages multiple :class:`~redis.asyncio.connection.Connection`
1463 objects for the (host, port).
1464 """
1465
1466 __slots__ = (
1467 "_background_tasks",
1468 "_connections",
1469 "_free",
1470 "_lock",
1471 "_event_dispatcher",
1472 "connection_class",
1473 "connection_kwargs",
1474 "host",
1475 "max_connections",
1476 "name",
1477 "port",
1478 "response_callbacks",
1479 "server_type",
1480 )
1481
1482 def __init__(
1483 self,
1484 host: str,
1485 port: Union[str, int],
1486 server_type: Optional[str] = None,
1487 *,
1488 max_connections: int = 100,
1489 connection_class: Type[Connection] = Connection,
1490 **connection_kwargs: Any,
1491 ) -> None:
1492 if host == "localhost":
1493 host = socket.gethostbyname(host)
1494
1495 connection_kwargs["host"] = host
1496 connection_kwargs["port"] = port
1497 self.host = host
1498 self.port = port
1499 self.name = get_node_name(host, port)
1500 self.server_type = server_type
1501
1502 self.max_connections = max_connections
1503 self.connection_class = connection_class
1504 self.connection_kwargs = connection_kwargs
1505 self.response_callbacks = connection_kwargs.pop("response_callbacks", {})
1506
1507 self._connections: List[Connection] = []
1508 self._free: Deque[Connection] = collections.deque(maxlen=self.max_connections)
1509 self._background_tasks: Set[asyncio.Task] = set()
1510 self._event_dispatcher = self.connection_kwargs.get("event_dispatcher", None)
1511 if self._event_dispatcher is None:
1512 self._event_dispatcher = EventDispatcher()
1513
1514 def __repr__(self) -> str:
1515 return (
1516 f"[host={self.host}, port={self.port}, "
1517 f"name={self.name}, server_type={self.server_type}]"
1518 )
1519
1520 def __eq__(self, obj: Any) -> bool:
1521 return isinstance(obj, ClusterNode) and obj.name == self.name
1522
1523 def __hash__(self) -> int:
1524 return hash(self.name)
1525
1526 _DEL_MESSAGE = "Unclosed ClusterNode object"
1527
1528 def __del__(
1529 self,
1530 _warn: Any = warnings.warn,
1531 _grl: Any = asyncio.get_running_loop,
1532 ) -> None:
1533 for connection in self._connections:
1534 if connection.is_connected:
1535 _warn(f"{self._DEL_MESSAGE} {self!r}", ResourceWarning, source=self)
1536
1537 try:
1538 context = {"client": self, "message": self._DEL_MESSAGE}
1539 _grl().call_exception_handler(context)
1540 except RuntimeError:
1541 pass
1542 break
1543
1544 async def disconnect(self) -> None:
1545 ret = await asyncio.gather(
1546 *(
1547 asyncio.create_task(connection.disconnect())
1548 for connection in self._connections
1549 ),
1550 return_exceptions=True,
1551 )
1552 exc = next((res for res in ret if isinstance(res, Exception)), None)
1553 if exc:
1554 raise exc
1555
1556 def acquire_connection(self) -> Connection:
1557 try:
1558 return self._free.popleft()
1559 except IndexError:
1560 if len(self._connections) < self.max_connections:
1561 # We are configuring the connection pool not to retry
1562 # connections on lower level clients to avoid retrying
1563 # connections to nodes that are not reachable
1564 # and to avoid blocking the connection pool.
1565 # The only error that will have some handling in the lower
1566 # level clients is ConnectionError which will trigger disconnection
1567 # of the socket.
1568 # The retries will be handled on cluster client level
1569 # where we will have proper handling of the cluster topology
1570 retry = Retry(
1571 backoff=NoBackoff(),
1572 retries=0,
1573 supported_errors=(ConnectionError,),
1574 )
1575 connection_kwargs = self.connection_kwargs.copy()
1576 connection_kwargs["retry"] = retry
1577 connection = self.connection_class(**connection_kwargs)
1578 self._connections.append(connection)
1579 return connection
1580
1581 raise MaxConnectionsError()
1582
1583 async def disconnect_if_needed(self, connection: Connection) -> None:
1584 """
1585 Disconnect a connection if it's marked for reconnect.
1586 This implements lazy disconnection to avoid race conditions.
1587 The connection will auto-reconnect on next use.
1588 """
1589 if connection.should_reconnect():
1590 await connection.disconnect()
1591
1592 def release(self, connection: Connection) -> None:
1593 """
1594 Release connection back to free queue.
1595 If the connection is marked for reconnect, disconnect it before
1596 returning it to the free queue.
1597 """
1598 if connection.should_reconnect():
1599 task = asyncio.create_task(self._disconnect_and_release(connection))
1600 self._background_tasks.add(task)
1601 task.add_done_callback(self._background_tasks.discard)
1602 return
1603 self._free.append(connection)
1604
1605 async def _disconnect_and_release(self, connection: Connection) -> None:
1606 try:
1607 await connection.disconnect()
1608 except Exception as exc:
1609 logger.debug(
1610 "disconnecting released cluster connection failed: %r",
1611 exc,
1612 exc_info=True,
1613 )
1614 try:
1615 self._connections.remove(connection)
1616 except ValueError:
1617 pass
1618 return
1619
1620 self._free.append(connection)
1621
1622 def get_encoder(self) -> Encoder:
1623 """Return an :class:`Encoder` derived from this node's connection kwargs."""
1624 kwargs = self.connection_kwargs
1625 encoder_class = kwargs.get("encoder_class", Encoder)
1626 return encoder_class(
1627 encoding=kwargs.get("encoding", "utf-8"),
1628 encoding_errors=kwargs.get("encoding_errors", "strict"),
1629 decode_responses=kwargs.get("decode_responses", False),
1630 )
1631
1632 def update_active_connections_for_reconnect(self) -> None:
1633 """
1634 Mark all in-use (active) connections for reconnect.
1635 In-use connections are those in _connections but not currently in _free.
1636 They will be disconnected after their current operation completes.
1637 """
1638 free_set = set(self._free)
1639 for connection in self._connections:
1640 if connection not in free_set:
1641 connection.mark_for_reconnect()
1642
1643 async def disconnect_free_connections(self) -> None:
1644 """
1645 Disconnect all free/idle connections in the pool.
1646 This is useful after topology changes (e.g., failover) to clear
1647 stale connection state like READONLY mode.
1648 The connections remain in the pool and will reconnect on next use.
1649 """
1650 if self._free:
1651 # Take a snapshot to avoid issues if _free changes during await
1652 await asyncio.gather(
1653 *(connection.disconnect() for connection in tuple(self._free)),
1654 return_exceptions=True,
1655 )
1656
1657 async def parse_response(
1658 self, connection: Connection, command: str, **kwargs: Any
1659 ) -> Any:
1660 try:
1661 if NEVER_DECODE in kwargs:
1662 response = await connection.read_response(disable_decoding=True)
1663 kwargs.pop(NEVER_DECODE)
1664 else:
1665 response = await connection.read_response()
1666 except ResponseError:
1667 if EMPTY_RESPONSE in kwargs:
1668 return kwargs[EMPTY_RESPONSE]
1669 raise
1670
1671 if EMPTY_RESPONSE in kwargs:
1672 kwargs.pop(EMPTY_RESPONSE)
1673
1674 # Remove keys entry, it needs only for cache.
1675 kwargs.pop("keys", None)
1676
1677 # Return response
1678 if command in self.response_callbacks:
1679 return self.response_callbacks[command](response, **kwargs)
1680
1681 return response
1682
1683 async def execute_command(self, *args: Any, **kwargs: Any) -> Any:
1684 # Acquire connection
1685 connection = self.acquire_connection()
1686 try:
1687 # Handle lazy disconnect for connections marked for reconnect
1688 await self.disconnect_if_needed(connection)
1689
1690 # Execute command
1691 await connection.send_packed_command(connection.pack_command(*args))
1692
1693 # Read response
1694 return await self.parse_response(connection, args[0], **kwargs)
1695 finally:
1696 try:
1697 await self.disconnect_if_needed(connection)
1698 finally:
1699 # Release connection
1700 self.release(connection)
1701
1702 async def execute_pipeline(self, commands: List["PipelineCommand"]) -> bool:
1703 # Acquire connection
1704 connection = self.acquire_connection()
1705 try:
1706 # Handle lazy disconnect for connections marked for reconnect
1707 await self.disconnect_if_needed(connection)
1708
1709 # Execute command
1710 await connection.send_packed_command(
1711 connection.pack_commands(cmd.args for cmd in commands)
1712 )
1713
1714 # Read responses
1715 ret = False
1716 for cmd in commands:
1717 try:
1718 cmd.result = await self.parse_response(
1719 connection, cmd.args[0], **cmd.kwargs
1720 )
1721 except Exception as e:
1722 cmd.result = e
1723 ret = True
1724
1725 return ret
1726 finally:
1727 try:
1728 await self.disconnect_if_needed(connection)
1729 finally:
1730 # Release connection
1731 self.release(connection)
1732
1733 async def re_auth_callback(self, token: TokenInterface):
1734 tmp_queue = collections.deque()
1735 while self._free:
1736 conn = self._free.popleft()
1737 await conn.retry.call_with_retry(
1738 lambda: conn.send_command(
1739 "AUTH", token.try_get("oid"), token.get_value()
1740 ),
1741 lambda error: self._mock(error),
1742 )
1743 await conn.retry.call_with_retry(
1744 lambda: conn.read_response(), lambda error: self._mock(error)
1745 )
1746 tmp_queue.append(conn)
1747
1748 while tmp_queue:
1749 conn = tmp_queue.popleft()
1750 self._free.append(conn)
1751
1752 async def _mock(self, error: RedisError):
1753 """
1754 Dummy functions, needs to be passed as error callback to retry object.
1755 :param error:
1756 :return:
1757 """
1758 pass
1759
1760
1761class NodesManager:
1762 __slots__ = (
1763 "_dynamic_startup_nodes",
1764 "_event_dispatcher",
1765 "_background_tasks",
1766 "connection_kwargs",
1767 "default_node",
1768 "nodes_cache",
1769 "_epoch",
1770 "read_load_balancer",
1771 "_initialize_lock",
1772 "require_full_coverage",
1773 "slots_cache",
1774 "startup_nodes",
1775 "address_remap",
1776 )
1777
1778 def __init__(
1779 self,
1780 startup_nodes: List["ClusterNode"],
1781 require_full_coverage: bool,
1782 connection_kwargs: Dict[str, Any],
1783 dynamic_startup_nodes: bool = True,
1784 address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None,
1785 event_dispatcher: Optional[EventDispatcher] = None,
1786 ) -> None:
1787 self.startup_nodes = {node.name: node for node in startup_nodes}
1788 self.require_full_coverage = require_full_coverage
1789 self.connection_kwargs = connection_kwargs
1790 self.address_remap = address_remap
1791
1792 self.default_node: "ClusterNode" = None
1793 self.nodes_cache: Dict[str, "ClusterNode"] = {}
1794 self.slots_cache: Dict[int, List["ClusterNode"]] = {}
1795 self._epoch: int = 0
1796 self.read_load_balancer = LoadBalancer()
1797 self._initialize_lock: asyncio.Lock = asyncio.Lock()
1798
1799 self._background_tasks: Set[asyncio.Task] = set()
1800 self._dynamic_startup_nodes: bool = dynamic_startup_nodes
1801 if event_dispatcher is None:
1802 self._event_dispatcher = EventDispatcher()
1803 else:
1804 self._event_dispatcher = event_dispatcher
1805
1806 def get_node(
1807 self,
1808 host: Optional[str] = None,
1809 port: Optional[int] = None,
1810 node_name: Optional[str] = None,
1811 ) -> Optional["ClusterNode"]:
1812 if host and port:
1813 # the user passed host and port
1814 if host == "localhost":
1815 host = socket.gethostbyname(host)
1816 return self.nodes_cache.get(get_node_name(host=host, port=port))
1817 elif node_name:
1818 return self.nodes_cache.get(node_name)
1819 else:
1820 raise DataError(
1821 "get_node requires one of the following: 1. node name 2. host and port"
1822 )
1823
1824 def set_nodes(
1825 self,
1826 old: Dict[str, "ClusterNode"],
1827 new: Dict[str, "ClusterNode"],
1828 remove_old: bool = False,
1829 ) -> None:
1830 if remove_old:
1831 for name in list(old.keys()):
1832 if name not in new:
1833 # Node is removed from cache before disconnect starts,
1834 # so it won't be found in lookups during disconnect
1835 # Mark active connections so in-flight commands can
1836 # finish, then disconnect them when their current
1837 # operation completes. Free connections can be
1838 # disconnected immediately.
1839 removed_node = old.pop(name)
1840 removed_node.update_active_connections_for_reconnect()
1841 task = asyncio.create_task(
1842 removed_node.disconnect_free_connections()
1843 )
1844 self._background_tasks.add(task)
1845 task.add_done_callback(self._background_tasks.discard)
1846
1847 for name, node in new.items():
1848 if name in old:
1849 # Preserve the existing node but mark connections for reconnect.
1850 # This method is sync so we can't call disconnect_free_connections()
1851 # which is async. Instead, we mark free connections for reconnect
1852 # and they will be lazily disconnected when acquired via
1853 # disconnect_if_needed() to avoid race conditions.
1854 # TODO: Make this method async in the next major release to allow
1855 # immediate disconnection of free connections.
1856 existing_node = old[name]
1857 existing_node.server_type = node.server_type
1858 existing_node.update_active_connections_for_reconnect()
1859 for conn in existing_node._free:
1860 conn.mark_for_reconnect()
1861 continue
1862 # New node is detected and should be added to the pool
1863 old[name] = node
1864
1865 def move_node_to_end_of_cached_nodes(self, node_name: str) -> None:
1866 """
1867 Move a failing node to the end of startup_nodes and nodes_cache so it's
1868 tried last during reinitialization and when selecting the default node.
1869 If the node is not in the respective list, nothing is done.
1870 """
1871 # Move in startup_nodes
1872 if node_name in self.startup_nodes and len(self.startup_nodes) > 1:
1873 node = self.startup_nodes.pop(node_name)
1874 self.startup_nodes[node_name] = node # Re-insert at end
1875
1876 # Move in nodes_cache - this affects get_nodes_by_server_type ordering
1877 # which is used to select the default_node during initialize()
1878 if node_name in self.nodes_cache and len(self.nodes_cache) > 1:
1879 node = self.nodes_cache.pop(node_name)
1880 self.nodes_cache[node_name] = node # Re-insert at end
1881
1882 async def move_slot(self, e: AskError | MovedError):
1883 node_changed = False
1884 redirected_node = self.get_node(host=e.host, port=e.port)
1885 if redirected_node:
1886 # The node already exists
1887 if redirected_node.server_type != PRIMARY:
1888 # Update the node's server type
1889 redirected_node.server_type = PRIMARY
1890 else:
1891 # This is a new node, we will add it to the nodes cache
1892 redirected_node = ClusterNode(
1893 e.host, e.port, PRIMARY, **self.connection_kwargs
1894 )
1895 self.set_nodes(self.nodes_cache, {redirected_node.name: redirected_node})
1896 slot_nodes = self.slots_cache[e.slot_id]
1897 if redirected_node not in slot_nodes:
1898 # The new slot owner is a new server, or a server from a different
1899 # shard. We need to remove all current nodes from the slot's list
1900 # (including replications) and add just the new node.
1901 self.slots_cache[e.slot_id] = [redirected_node]
1902 node_changed = True
1903 elif redirected_node is not slot_nodes[0]:
1904 # The MOVED error resulted from a failover, and the new slot owner
1905 # had previously been a replica.
1906 old_primary = slot_nodes[0]
1907 # Update the old primary to be a replica and add it to the end of
1908 # the slot's node list
1909 old_primary.server_type = REPLICA
1910 slot_nodes.append(old_primary)
1911 # Remove the old replica, which is now a primary, from the slot's
1912 # node list
1913 slot_nodes.remove(redirected_node)
1914 # Override the old primary with the new one
1915 slot_nodes[0] = redirected_node
1916 if self.default_node == old_primary:
1917 # Update the default node with the new primary
1918 self.default_node = redirected_node
1919 node_changed = True
1920 # else: circular MOVED to current primary -> no-op
1921 # Dispatch so listeners can run shard-pubsub reconciliation; skipped on
1922 # the no-op branch to avoid needless walks under MOVED storms. A
1923 # listener must not break slots-cache refresh; log and continue so a
1924 # single buggy listener cannot starve the rest.
1925 if node_changed:
1926 try:
1927 await self._event_dispatcher.dispatch_async(
1928 AsyncAfterSlotsCacheRefreshEvent()
1929 )
1930 except Exception as exc:
1931 # Don't shadow the method parameter ``e``: ``except as`` binds
1932 # the listener exception in the function scope and ``del``s
1933 # the name on block exit (PEP 3134), which would also wipe
1934 # out the original AskError/MovedError parameter.
1935 logger.exception(
1936 "listener raised during slots-cache refresh: %s: %s",
1937 type(exc).__name__,
1938 exc,
1939 )
1940
1941 def get_node_from_slot(
1942 self,
1943 slot: int,
1944 read_from_replicas: bool = False,
1945 load_balancing_strategy=None,
1946 ) -> "ClusterNode":
1947 if read_from_replicas is True and load_balancing_strategy is None:
1948 load_balancing_strategy = LoadBalancingStrategy.ROUND_ROBIN
1949
1950 try:
1951 if len(self.slots_cache[slot]) > 1 and load_balancing_strategy:
1952 # get the server index using the strategy defined in load_balancing_strategy
1953 primary_name = self.slots_cache[slot][0].name
1954 node_idx = self.read_load_balancer.get_server_index(
1955 primary_name, len(self.slots_cache[slot]), load_balancing_strategy
1956 )
1957 return self.slots_cache[slot][node_idx]
1958 return self.slots_cache[slot][0]
1959 except (IndexError, TypeError):
1960 raise SlotNotCoveredError(
1961 f'Slot "{slot}" not covered by the cluster. '
1962 f'"require_full_coverage={self.require_full_coverage}"'
1963 )
1964
1965 def get_nodes_by_server_type(self, server_type: str) -> List["ClusterNode"]:
1966 return [
1967 node
1968 for node in self.nodes_cache.values()
1969 if node.server_type == server_type
1970 ]
1971
1972 async def initialize(
1973 self,
1974 additional_startup_nodes_info: Optional[List[Tuple[str, int]]] = None,
1975 last_failed_node_name: Optional[str] = None,
1976 ) -> None:
1977 self.read_load_balancer.reset()
1978 tmp_nodes_cache: Dict[str, "ClusterNode"] = {}
1979 tmp_slots: Dict[int, List["ClusterNode"]] = {}
1980 disagreements = []
1981 startup_nodes_reachable = False
1982 fully_covered = False
1983 exception = None
1984 epoch = self._epoch
1985 if additional_startup_nodes_info is None:
1986 additional_startup_nodes_info = []
1987
1988 async with self._initialize_lock:
1989 if self._epoch != epoch:
1990 # another initialize call has already reinitialized the
1991 # nodes since we started waiting for the lock;
1992 # we don't need to do it again.
1993 return
1994
1995 # Copy to a list to prevent RuntimeError if self.startup_nodes
1996 # is modified during iteration, then shuffle the iteration order.
1997 startup_nodes = list(self.startup_nodes.values())
1998 deferred_failed_nodes = []
1999 if last_failed_node_name is not None:
2000 for index, node in enumerate(startup_nodes):
2001 if node.name == last_failed_node_name:
2002 deferred_failed_nodes.append(startup_nodes.pop(index))
2003 break
2004 if len(startup_nodes) > 1:
2005 # Vary which startup node is queried first so clients do not
2006 # all reinitialize through the same node.
2007 random.shuffle(startup_nodes)
2008 additional_startup_nodes = [
2009 ClusterNode(host, port, **self.connection_kwargs)
2010 for host, port in additional_startup_nodes_info
2011 ]
2012 if last_failed_node_name is not None:
2013 for index, node in enumerate(additional_startup_nodes):
2014 if node.name == last_failed_node_name:
2015 if not deferred_failed_nodes:
2016 deferred_failed_nodes.append(node)
2017 additional_startup_nodes.pop(index)
2018 break
2019 for startup_node in chain(
2020 startup_nodes,
2021 additional_startup_nodes,
2022 deferred_failed_nodes,
2023 ):
2024 try:
2025 # Make sure cluster mode is enabled on this node
2026 try:
2027 self._event_dispatcher.dispatch(
2028 AfterAsyncClusterInstantiationEvent(
2029 self.nodes_cache,
2030 self.connection_kwargs.get("credential_provider", None),
2031 )
2032 )
2033 cluster_slots = await startup_node.execute_command(
2034 "CLUSTER SLOTS"
2035 )
2036 except ResponseError:
2037 raise RedisClusterException(
2038 "Cluster mode is not enabled on this node"
2039 )
2040 startup_nodes_reachable = True
2041 except Exception as e:
2042 # Try the next startup node.
2043 # The exception is saved and raised only if we have no more nodes.
2044 exception = e
2045 continue
2046
2047 # CLUSTER SLOTS command results in the following output:
2048 # [[slot_section[from_slot,to_slot,master,replica1,...,replicaN]]]
2049 # where each node contains the following list: [IP, port, node_id]
2050 # Therefore, cluster_slots[0][2][0] will be the IP address of the
2051 # primary node of the first slot section.
2052 # If there's only one server in the cluster, its ``host`` is ''
2053 # Fix it to the host in startup_nodes
2054 if (
2055 len(cluster_slots) == 1
2056 and not cluster_slots[0][2][0]
2057 and len(self.startup_nodes) == 1
2058 ):
2059 cluster_slots[0][2][0] = startup_node.host
2060
2061 for slot in cluster_slots:
2062 for i in range(2, len(slot)):
2063 slot[i] = [str_if_bytes(val) for val in slot[i]]
2064 primary_node = slot[2]
2065 host = primary_node[0]
2066 if host == "":
2067 host = startup_node.host
2068 port = int(primary_node[1])
2069 host, port = self.remap_host_port(host, port)
2070
2071 nodes_for_slot = []
2072
2073 target_node = tmp_nodes_cache.get(get_node_name(host, port))
2074 if not target_node:
2075 target_node = ClusterNode(
2076 host, port, PRIMARY, **self.connection_kwargs
2077 )
2078 # add this node to the nodes cache
2079 tmp_nodes_cache[target_node.name] = target_node
2080 nodes_for_slot.append(target_node)
2081
2082 replica_nodes = slot[3:]
2083 for replica_node in replica_nodes:
2084 host = replica_node[0]
2085 port = replica_node[1]
2086 host, port = self.remap_host_port(host, port)
2087
2088 target_replica_node = tmp_nodes_cache.get(
2089 get_node_name(host, port)
2090 )
2091 if not target_replica_node:
2092 target_replica_node = ClusterNode(
2093 host, port, REPLICA, **self.connection_kwargs
2094 )
2095 # add this node to the nodes cache
2096 tmp_nodes_cache[target_replica_node.name] = target_replica_node
2097 nodes_for_slot.append(target_replica_node)
2098
2099 for i in range(int(slot[0]), int(slot[1]) + 1):
2100 if i not in tmp_slots:
2101 tmp_slots[i] = nodes_for_slot
2102 else:
2103 # Validate that 2 nodes want to use the same slot cache
2104 # setup
2105 tmp_slot = tmp_slots[i][0]
2106 if tmp_slot.name != target_node.name:
2107 disagreements.append(
2108 f"{tmp_slot.name} vs {target_node.name} on slot: {i}"
2109 )
2110
2111 if len(disagreements) > 5:
2112 raise RedisClusterException(
2113 f"startup_nodes could not agree on a valid "
2114 f"slots cache: {', '.join(disagreements)}"
2115 )
2116
2117 # Validate if all slots are covered or if we should try next startup node
2118 fully_covered = True
2119 for i in range(REDIS_CLUSTER_HASH_SLOTS):
2120 if i not in tmp_slots:
2121 fully_covered = False
2122 break
2123 if fully_covered:
2124 break
2125
2126 if not startup_nodes_reachable:
2127 raise RedisClusterException(
2128 f"Redis Cluster cannot be connected. Please provide at least "
2129 f"one reachable node: {str(exception)}"
2130 ) from exception
2131
2132 # Check if the slots are not fully covered
2133 if not fully_covered and self.require_full_coverage:
2134 # Despite the requirement that the slots be covered, there
2135 # isn't a full coverage
2136 raise RedisClusterException(
2137 f"All slots are not covered after query all startup_nodes. "
2138 f"{len(tmp_slots)} of {REDIS_CLUSTER_HASH_SLOTS} "
2139 f"covered..."
2140 )
2141
2142 # Set the tmp variables to the real variables
2143 self.set_nodes(self.nodes_cache, tmp_nodes_cache, remove_old=True)
2144 # tmp_slots was built from CLUSTER SLOTS responses and can contain
2145 # newly-created ClusterNode objects for nodes we already know about.
2146 # Rebuild the slots cache with the preserved nodes_cache instances
2147 # so existing per-node connection pools stay in use after refresh.
2148 # Keep the shared node-list-per-slot-range shape from tmp_slots to
2149 # avoid allocating a separate list for every slot.
2150 node_lists_by_id: Dict[int, List["ClusterNode"]] = {}
2151 new_slots_cache: Dict[int, List["ClusterNode"]] = {}
2152 for slot, nodes in tmp_slots.items():
2153 node_list_id = id(nodes)
2154 slot_nodes = node_lists_by_id.get(node_list_id)
2155 if slot_nodes is None:
2156 slot_nodes = [self.nodes_cache[node.name] for node in nodes]
2157 node_lists_by_id[node_list_id] = slot_nodes
2158 new_slots_cache[slot] = slot_nodes
2159 self.slots_cache = new_slots_cache
2160
2161 if self._dynamic_startup_nodes:
2162 # Populate the startup nodes with all discovered nodes
2163 self.set_nodes(self.startup_nodes, self.nodes_cache, remove_old=True)
2164
2165 # Set the default node
2166 self.default_node = self.get_nodes_by_server_type(PRIMARY)[0]
2167 self._epoch += 1
2168 # Dispatch so listeners (e.g. ClusterPubSub) can reconcile per-node
2169 # state after slot ownership may have changed. A listener must not
2170 # break slots-cache refresh; log and continue so a single buggy
2171 # listener cannot starve the rest.
2172 try:
2173 await self._event_dispatcher.dispatch_async(
2174 AsyncAfterSlotsCacheRefreshEvent()
2175 )
2176 except Exception as e:
2177 logger.exception(
2178 "listener raised during slots-cache refresh: %s: %s",
2179 type(e).__name__,
2180 e,
2181 )
2182
2183 async def aclose(self, attr: str = "nodes_cache") -> None:
2184 self.default_node = None
2185 await asyncio.gather(
2186 *(
2187 asyncio.create_task(node.disconnect())
2188 for node in getattr(self, attr).values()
2189 )
2190 )
2191
2192 def remap_host_port(self, host: str, port: int) -> Tuple[str, int]:
2193 """
2194 Remap the host and port returned from the cluster to a different
2195 internal value. Useful if the client is not connecting directly
2196 to the cluster.
2197 """
2198 if self.address_remap:
2199 return self.address_remap((host, port))
2200 return host, port
2201
2202
2203class ClusterPipeline(AbstractRedis, AbstractRedisCluster, AsyncRedisClusterCommands):
2204 """
2205 Create a new ClusterPipeline object.
2206
2207 Usage::
2208
2209 result = await (
2210 rc.pipeline()
2211 .set("A", 1)
2212 .get("A")
2213 .hset("K", "F", "V")
2214 .hgetall("K")
2215 .mset_nonatomic({"A": 2, "B": 3})
2216 .get("A")
2217 .get("B")
2218 .delete("A", "B", "K")
2219 .execute()
2220 )
2221 # result = [True, "1", 1, {"F": "V"}, True, True, "2", "3", 1, 1, 1]
2222
2223 Note: For commands `DELETE`, `EXISTS`, `TOUCH`, `UNLINK`, `mset_nonatomic`, which
2224 are split across multiple nodes, you'll get multiple results for them in the array.
2225
2226 Retryable errors:
2227 - :class:`~.ClusterDownError`
2228 - :class:`~.ConnectionError`
2229 - :class:`~.TimeoutError`
2230
2231 Redirection errors:
2232 - :class:`~.TryAgainError`
2233 - :class:`~.MovedError`
2234 - :class:`~.AskError`
2235
2236 :param client:
2237 | Existing :class:`~.RedisCluster` client
2238 """
2239
2240 __slots__ = (
2241 "cluster_client",
2242 "_transaction",
2243 "_execution_strategy",
2244 )
2245
2246 # Type discrimination marker for @overload self-type pattern
2247 _is_async_client: Literal[True] = True
2248
2249 def __init__(
2250 self, client: RedisCluster, transaction: Optional[bool] = None
2251 ) -> None:
2252 self.cluster_client = client
2253 self._transaction = transaction
2254 self._execution_strategy: ExecutionStrategy = (
2255 PipelineStrategy(self)
2256 if not self._transaction
2257 else TransactionStrategy(self)
2258 )
2259
2260 @property
2261 def nodes_manager(self) -> "NodesManager":
2262 """Get the nodes manager from the cluster client."""
2263 return self.cluster_client.nodes_manager
2264
2265 def set_response_callback(self, command: str, callback: ResponseCallbackT) -> None:
2266 """Set a custom response callback on the cluster client."""
2267 self.cluster_client.set_response_callback(command, callback)
2268
2269 async def initialize(self) -> "ClusterPipeline":
2270 await self._execution_strategy.initialize()
2271 return self
2272
2273 async def __aenter__(self) -> "ClusterPipeline":
2274 return await self.initialize()
2275
2276 async def __aexit__(self, exc_type: None, exc_value: None, traceback: None) -> None:
2277 await self.reset()
2278
2279 def __await__(self) -> Generator[Any, None, "ClusterPipeline"]:
2280 return self.initialize().__await__()
2281
2282 def __bool__(self) -> bool:
2283 "Pipeline instances should always evaluate to True on Python 3+"
2284 return True
2285
2286 def __len__(self) -> int:
2287 return len(self._execution_strategy)
2288
2289 def execute_command(
2290 self, *args: Union[KeyT, EncodableT], **kwargs: Any
2291 ) -> "ClusterPipeline":
2292 """
2293 Append a raw command to the pipeline.
2294
2295 :param args:
2296 | Raw command args
2297 :param kwargs:
2298
2299 - target_nodes: :attr:`NODE_FLAGS` or :class:`~.ClusterNode`
2300 or List[:class:`~.ClusterNode`] or Dict[Any, :class:`~.ClusterNode`]
2301 - Rest of the kwargs are passed to the Redis connection
2302 """
2303 return self._execution_strategy.execute_command(*args, **kwargs)
2304
2305 async def execute(
2306 self, raise_on_error: bool = True, allow_redirections: bool = True
2307 ) -> List[Any]:
2308 """
2309 Execute the pipeline.
2310
2311 It will retry the commands as specified by retries specified in :attr:`retry`
2312 & then raise an exception.
2313
2314 :param raise_on_error:
2315 | Raise the first error if there are any errors
2316 :param allow_redirections:
2317 | Whether to retry each failed command individually in case of redirection
2318 errors
2319
2320 :raises RedisClusterException: if target_nodes is not provided & the command
2321 can't be mapped to a slot
2322 """
2323 try:
2324 return await self._execution_strategy.execute(
2325 raise_on_error, allow_redirections
2326 )
2327 finally:
2328 await self.reset()
2329
2330 def _split_command_across_slots(
2331 self, command: str, *keys: KeyT
2332 ) -> "ClusterPipeline":
2333 for slot_keys in self.cluster_client._partition_keys_by_slot(keys).values():
2334 self.execute_command(command, *slot_keys)
2335
2336 return self
2337
2338 async def reset(self):
2339 """
2340 Reset back to empty pipeline.
2341 """
2342 await self._execution_strategy.reset()
2343
2344 def multi(self):
2345 """
2346 Start a transactional block of the pipeline after WATCH commands
2347 are issued. End the transactional block with `execute`.
2348 """
2349 self._execution_strategy.multi()
2350
2351 async def discard(self):
2352 """ """
2353 await self._execution_strategy.discard()
2354
2355 async def watch(self, *names):
2356 """Watches the values at keys ``names``"""
2357 await self._execution_strategy.watch(*names)
2358
2359 async def unwatch(self):
2360 """Unwatches all previously specified keys"""
2361 await self._execution_strategy.unwatch()
2362
2363 async def unlink(self, *names):
2364 await self._execution_strategy.unlink(*names)
2365
2366 def mset_nonatomic(
2367 self, mapping: Mapping[AnyKeyT, EncodableT]
2368 ) -> "ClusterPipeline":
2369 return self._execution_strategy.mset_nonatomic(mapping)
2370
2371
2372for command in PIPELINE_BLOCKED_COMMANDS:
2373 command = command.replace(" ", "_").lower()
2374 if command == "mset_nonatomic":
2375 continue
2376
2377 setattr(ClusterPipeline, command, block_pipeline_command(command))
2378
2379
2380class PipelineCommand:
2381 def __init__(self, position: int, *args: Any, **kwargs: Any) -> None:
2382 self.args = args
2383 self.kwargs = kwargs
2384 self.position = position
2385 self.result: Union[Any, Exception] = None
2386 self.command_policies: Optional[CommandPolicies] = None
2387
2388 def __repr__(self) -> str:
2389 return f"[{self.position}] {self.args} ({self.kwargs})"
2390
2391
2392class ExecutionStrategy(ABC):
2393 @abstractmethod
2394 async def initialize(self) -> "ClusterPipeline":
2395 """
2396 Initialize the execution strategy.
2397
2398 See ClusterPipeline.initialize()
2399 """
2400 pass
2401
2402 @abstractmethod
2403 def execute_command(
2404 self, *args: Union[KeyT, EncodableT], **kwargs: Any
2405 ) -> "ClusterPipeline":
2406 """
2407 Append a raw command to the pipeline.
2408
2409 See ClusterPipeline.execute_command()
2410 """
2411 pass
2412
2413 @abstractmethod
2414 async def execute(
2415 self, raise_on_error: bool = True, allow_redirections: bool = True
2416 ) -> List[Any]:
2417 """
2418 Execute the pipeline.
2419
2420 It will retry the commands as specified by retries specified in :attr:`retry`
2421 & then raise an exception.
2422
2423 See ClusterPipeline.execute()
2424 """
2425 pass
2426
2427 @abstractmethod
2428 def mset_nonatomic(
2429 self, mapping: Mapping[AnyKeyT, EncodableT]
2430 ) -> "ClusterPipeline":
2431 """
2432 Executes multiple MSET commands according to the provided slot/pairs mapping.
2433
2434 See ClusterPipeline.mset_nonatomic()
2435 """
2436 pass
2437
2438 @abstractmethod
2439 async def reset(self):
2440 """
2441 Resets current execution strategy.
2442
2443 See: ClusterPipeline.reset()
2444 """
2445 pass
2446
2447 @abstractmethod
2448 def multi(self):
2449 """
2450 Starts transactional context.
2451
2452 See: ClusterPipeline.multi()
2453 """
2454 pass
2455
2456 @abstractmethod
2457 async def watch(self, *names):
2458 """
2459 Watch given keys.
2460
2461 See: ClusterPipeline.watch()
2462 """
2463 pass
2464
2465 @abstractmethod
2466 async def unwatch(self):
2467 """
2468 Unwatches all previously specified keys
2469
2470 See: ClusterPipeline.unwatch()
2471 """
2472 pass
2473
2474 @abstractmethod
2475 async def discard(self):
2476 pass
2477
2478 @abstractmethod
2479 async def unlink(self, *names):
2480 """
2481 "Unlink a key specified by ``names``"
2482
2483 See: ClusterPipeline.unlink()
2484 """
2485 pass
2486
2487 @abstractmethod
2488 def __len__(self) -> int:
2489 pass
2490
2491
2492class AbstractStrategy(ExecutionStrategy):
2493 def __init__(self, pipe: ClusterPipeline) -> None:
2494 self._pipe: ClusterPipeline = pipe
2495 self._command_queue: List["PipelineCommand"] = []
2496
2497 async def initialize(self) -> "ClusterPipeline":
2498 if self._pipe.cluster_client._initialize:
2499 await self._pipe.cluster_client.initialize()
2500 self._command_queue = []
2501 return self._pipe
2502
2503 def execute_command(
2504 self, *args: Union[KeyT, EncodableT], **kwargs: Any
2505 ) -> "ClusterPipeline":
2506 self._command_queue.append(
2507 PipelineCommand(len(self._command_queue), *args, **kwargs)
2508 )
2509 return self._pipe
2510
2511 def _annotate_exception(self, exception, number, command):
2512 """
2513 Provides extra context to the exception prior to it being handled
2514 """
2515 cmd = " ".join(map(safe_str, command))
2516 msg = (
2517 f"Command # {number} ({truncate_text(cmd)}) of pipeline "
2518 f"caused error: {exception.args[0]}"
2519 )
2520 exception.args = (msg,) + exception.args[1:]
2521
2522 @abstractmethod
2523 def mset_nonatomic(
2524 self, mapping: Mapping[AnyKeyT, EncodableT]
2525 ) -> "ClusterPipeline":
2526 pass
2527
2528 @abstractmethod
2529 async def execute(
2530 self, raise_on_error: bool = True, allow_redirections: bool = True
2531 ) -> List[Any]:
2532 pass
2533
2534 @abstractmethod
2535 async def reset(self):
2536 pass
2537
2538 @abstractmethod
2539 def multi(self):
2540 pass
2541
2542 @abstractmethod
2543 async def watch(self, *names):
2544 pass
2545
2546 @abstractmethod
2547 async def unwatch(self):
2548 pass
2549
2550 @abstractmethod
2551 async def discard(self):
2552 pass
2553
2554 @abstractmethod
2555 async def unlink(self, *names):
2556 pass
2557
2558 def __len__(self) -> int:
2559 return len(self._command_queue)
2560
2561
2562class PipelineStrategy(AbstractStrategy):
2563 def __init__(self, pipe: ClusterPipeline) -> None:
2564 super().__init__(pipe)
2565
2566 def mset_nonatomic(
2567 self, mapping: Mapping[AnyKeyT, EncodableT]
2568 ) -> "ClusterPipeline":
2569 encoder = self._pipe.cluster_client.encoder
2570
2571 slots_pairs = {}
2572 for pair in mapping.items():
2573 slot = key_slot(encoder.encode(pair[0]))
2574 slots_pairs.setdefault(slot, []).extend(pair)
2575
2576 for pairs in slots_pairs.values():
2577 self.execute_command("MSET", *pairs)
2578
2579 return self._pipe
2580
2581 async def execute(
2582 self, raise_on_error: bool = True, allow_redirections: bool = True
2583 ) -> List[Any]:
2584 if not self._command_queue:
2585 return []
2586
2587 try:
2588 retry_attempts = self._pipe.cluster_client.retry.get_retries()
2589 while True:
2590 try:
2591 if self._pipe.cluster_client._initialize:
2592 await self._pipe.cluster_client.initialize()
2593 return await self._execute(
2594 self._pipe.cluster_client,
2595 self._command_queue,
2596 raise_on_error=raise_on_error,
2597 allow_redirections=allow_redirections,
2598 )
2599
2600 except RedisCluster.ERRORS_ALLOW_RETRY as e:
2601 if retry_attempts > 0:
2602 # Try again with the new cluster setup. All other errors
2603 # should be raised.
2604 retry_attempts -= 1
2605 await self._pipe.cluster_client.aclose()
2606 await asyncio.sleep(0.25)
2607 else:
2608 # All other errors should be raised.
2609 raise e
2610 finally:
2611 await self.reset()
2612
2613 async def _execute(
2614 self,
2615 client: "RedisCluster",
2616 stack: List["PipelineCommand"],
2617 raise_on_error: bool = True,
2618 allow_redirections: bool = True,
2619 ) -> List[Any]:
2620 todo = [
2621 cmd for cmd in stack if not cmd.result or isinstance(cmd.result, Exception)
2622 ]
2623
2624 nodes = {}
2625 for cmd in todo:
2626 passed_targets = cmd.kwargs.pop("target_nodes", None)
2627 command_policies = await client._policy_resolver.resolve(
2628 cmd.args[0].lower()
2629 )
2630
2631 if passed_targets and not client._is_node_flag(passed_targets):
2632 target_nodes = client._parse_target_nodes(passed_targets)
2633
2634 if not command_policies:
2635 command_policies = CommandPolicies()
2636 else:
2637 if not command_policies:
2638 command_flag = client.command_flags.get(cmd.args[0])
2639 if not command_flag:
2640 # Fallback to default policy
2641 if not client.get_default_node():
2642 slot = None
2643 else:
2644 slot = await client._determine_slot(*cmd.args)
2645 if slot is None:
2646 command_policies = CommandPolicies()
2647 else:
2648 command_policies = CommandPolicies(
2649 request_policy=RequestPolicy.DEFAULT_KEYED,
2650 response_policy=ResponsePolicy.DEFAULT_KEYED,
2651 )
2652 else:
2653 if command_flag in client._command_flags_mapping:
2654 command_policies = CommandPolicies(
2655 request_policy=client._command_flags_mapping[
2656 command_flag
2657 ]
2658 )
2659 else:
2660 command_policies = CommandPolicies()
2661
2662 target_nodes = await client._determine_nodes(
2663 *cmd.args,
2664 request_policy=command_policies.request_policy,
2665 node_flag=passed_targets,
2666 )
2667 if not target_nodes:
2668 raise RedisClusterException(
2669 f"No targets were found to execute {cmd.args} command on"
2670 )
2671 cmd.command_policies = command_policies
2672 if len(target_nodes) > 1:
2673 raise RedisClusterException(f"Too many targets for command {cmd.args}")
2674 node = target_nodes[0]
2675 if node.name not in nodes:
2676 nodes[node.name] = (node, [])
2677 nodes[node.name][1].append(cmd)
2678
2679 # Start timing for observability
2680 start_time = time.monotonic()
2681
2682 errors = await asyncio.gather(
2683 *(
2684 asyncio.create_task(node[0].execute_pipeline(node[1]))
2685 for node in nodes.values()
2686 )
2687 )
2688
2689 # Record operation duration for each node
2690 for node_name, (node, commands) in nodes.items():
2691 # Find the first error in this node's commands, if any
2692 node_error = None
2693 for cmd in commands:
2694 if isinstance(cmd.result, Exception):
2695 node_error = cmd.result
2696 break
2697
2698 db = node.connection_kwargs.get("db", 0)
2699 await record_operation_duration(
2700 command_name="PIPELINE",
2701 duration_seconds=time.monotonic() - start_time,
2702 server_address=node.host,
2703 server_port=node.port,
2704 db_namespace=str(db) if db is not None else None,
2705 error=node_error,
2706 )
2707
2708 if any(errors):
2709 if allow_redirections:
2710 # send each errored command individually
2711 for cmd in todo:
2712 if isinstance(cmd.result, (TryAgainError, MovedError, AskError)):
2713 try:
2714 cmd.result = client._policies_callback_mapping[
2715 cmd.command_policies.response_policy
2716 ](await client.execute_command(*cmd.args, **cmd.kwargs))
2717 except Exception as e:
2718 cmd.result = e
2719
2720 if raise_on_error:
2721 for cmd in todo:
2722 result = cmd.result
2723 if isinstance(result, Exception):
2724 command = " ".join(map(safe_str, cmd.args))
2725 msg = (
2726 f"Command # {cmd.position + 1} "
2727 f"({truncate_text(command)}) "
2728 f"of pipeline caused error: {result.args}"
2729 )
2730 result.args = (msg,) + result.args[1:]
2731 raise result
2732
2733 default_cluster_node = client.get_default_node()
2734
2735 # Check whether the default node was used. In some cases,
2736 # 'client.get_default_node()' may return None. The check below
2737 # prevents a potential AttributeError.
2738 if default_cluster_node is not None:
2739 default_node = nodes.get(default_cluster_node.name)
2740 if default_node is not None:
2741 # This pipeline execution used the default node, check if we need
2742 # to replace it.
2743 # Note: when the error is raised we'll reset the default node in the
2744 # caller function.
2745 for cmd in default_node[1]:
2746 # Check if it has a command that failed with a relevant
2747 # exception
2748 if type(cmd.result) in RedisCluster.ERRORS_ALLOW_RETRY:
2749 client.replace_default_node()
2750 break
2751
2752 return [cmd.result for cmd in stack]
2753
2754 async def reset(self):
2755 """
2756 Reset back to empty pipeline.
2757 """
2758 self._command_queue = []
2759
2760 def multi(self):
2761 raise RedisClusterException(
2762 "method multi() is not supported outside of transactional context"
2763 )
2764
2765 async def watch(self, *names):
2766 raise RedisClusterException(
2767 "method watch() is not supported outside of transactional context"
2768 )
2769
2770 async def unwatch(self):
2771 raise RedisClusterException(
2772 "method unwatch() is not supported outside of transactional context"
2773 )
2774
2775 async def discard(self):
2776 raise RedisClusterException(
2777 "method discard() is not supported outside of transactional context"
2778 )
2779
2780 async def unlink(self, *names):
2781 if len(names) != 1:
2782 raise RedisClusterException(
2783 "unlinking multiple keys is not implemented in pipeline command"
2784 )
2785
2786 return self.execute_command("UNLINK", names[0])
2787
2788
2789class TransactionStrategy(AbstractStrategy):
2790 NO_SLOTS_COMMANDS = {"UNWATCH"}
2791 IMMEDIATE_EXECUTE_COMMANDS = {"WATCH", "UNWATCH"}
2792 UNWATCH_COMMANDS = {"DISCARD", "EXEC", "UNWATCH"}
2793 SLOT_REDIRECT_ERRORS = (AskError, MovedError)
2794 CONNECTION_ERRORS = (
2795 ConnectionError,
2796 OSError,
2797 ClusterDownError,
2798 SlotNotCoveredError,
2799 )
2800
2801 def __init__(self, pipe: ClusterPipeline) -> None:
2802 super().__init__(pipe)
2803 self._explicit_transaction = False
2804 self._watching = False
2805 self._pipeline_slots: Set[int] = set()
2806 self._transaction_node: Optional[ClusterNode] = None
2807 self._transaction_connection: Optional[Connection] = None
2808 self._executing = False
2809 self._retry = copy(self._pipe.cluster_client.retry)
2810 self._retry.update_supported_errors(
2811 RedisCluster.ERRORS_ALLOW_RETRY + self.SLOT_REDIRECT_ERRORS
2812 )
2813
2814 def _get_client_and_connection_for_transaction(
2815 self,
2816 ) -> Tuple[ClusterNode, Connection]:
2817 """
2818 Find a connection for a pipeline transaction.
2819
2820 For running an atomic transaction, watch keys ensure that contents have not been
2821 altered as long as the watch commands for those keys were sent over the same
2822 connection. So once we start watching a key, we fetch a connection to the
2823 node that owns that slot and reuse it.
2824 """
2825 if not self._pipeline_slots:
2826 raise RedisClusterException(
2827 "At least a command with a key is needed to identify a node"
2828 )
2829
2830 node: ClusterNode = self._pipe.cluster_client.nodes_manager.get_node_from_slot(
2831 list(self._pipeline_slots)[0], False
2832 )
2833 self._transaction_node = node
2834
2835 if not self._transaction_connection:
2836 connection: Connection = self._transaction_node.acquire_connection()
2837 self._transaction_connection = connection
2838
2839 return self._transaction_node, self._transaction_connection
2840
2841 def execute_command(self, *args: Union[KeyT, EncodableT], **kwargs: Any) -> "Any":
2842 # Given the limitation of ClusterPipeline sync API, we have to run it in thread.
2843 response = None
2844 error = None
2845
2846 def runner():
2847 nonlocal response
2848 nonlocal error
2849 try:
2850 response = asyncio.run(self._execute_command(*args, **kwargs))
2851 except Exception as e:
2852 error = e
2853
2854 thread = threading.Thread(target=runner)
2855 thread.start()
2856 thread.join()
2857
2858 if error:
2859 raise error
2860
2861 return response
2862
2863 async def _execute_command(
2864 self, *args: Union[KeyT, EncodableT], **kwargs: Any
2865 ) -> Any:
2866 if self._pipe.cluster_client._initialize:
2867 await self._pipe.cluster_client.initialize()
2868
2869 slot_number: Optional[int] = None
2870 if args[0] not in self.NO_SLOTS_COMMANDS:
2871 slot_number = await self._pipe.cluster_client._determine_slot(*args)
2872
2873 if (
2874 self._watching or args[0] in self.IMMEDIATE_EXECUTE_COMMANDS
2875 ) and not self._explicit_transaction:
2876 if args[0] == "WATCH":
2877 self._validate_watch()
2878
2879 if slot_number is not None:
2880 if self._pipeline_slots and slot_number not in self._pipeline_slots:
2881 raise CrossSlotTransactionError(
2882 "Cannot watch or send commands on different slots"
2883 )
2884
2885 self._pipeline_slots.add(slot_number)
2886 elif args[0] not in self.NO_SLOTS_COMMANDS:
2887 raise RedisClusterException(
2888 f"Cannot identify slot number for command: {args[0]},"
2889 "it cannot be triggered in a transaction"
2890 )
2891
2892 return self._immediate_execute_command(*args, **kwargs)
2893 else:
2894 if slot_number is not None:
2895 self._pipeline_slots.add(slot_number)
2896
2897 return super().execute_command(*args, **kwargs)
2898
2899 def _validate_watch(self):
2900 if self._explicit_transaction:
2901 raise RedisError("Cannot issue a WATCH after a MULTI")
2902
2903 self._watching = True
2904
2905 async def _immediate_execute_command(self, *args, **options):
2906 return await self._retry.call_with_retry(
2907 lambda: self._get_connection_and_send_command(*args, **options),
2908 self._reinitialize_on_error,
2909 with_failure_count=True,
2910 )
2911
2912 async def _get_connection_and_send_command(self, *args, **options):
2913 redis_node, connection = self._get_client_and_connection_for_transaction()
2914 # Only disconnect if not watching - disconnecting would lose WATCH state
2915 if not self._watching:
2916 await redis_node.disconnect_if_needed(connection)
2917
2918 # Start timing for observability
2919 start_time = time.monotonic()
2920
2921 try:
2922 response = await self._send_command_parse_response(
2923 connection, redis_node, args[0], *args, **options
2924 )
2925
2926 await record_operation_duration(
2927 command_name=args[0],
2928 duration_seconds=time.monotonic() - start_time,
2929 server_address=connection.host,
2930 server_port=connection.port,
2931 db_namespace=str(connection.db),
2932 )
2933
2934 return response
2935 except Exception as e:
2936 e.connection = connection
2937 await record_operation_duration(
2938 command_name=args[0],
2939 duration_seconds=time.monotonic() - start_time,
2940 server_address=connection.host,
2941 server_port=connection.port,
2942 db_namespace=str(connection.db),
2943 error=e,
2944 )
2945 raise
2946
2947 async def _send_command_parse_response(
2948 self,
2949 connection: Connection,
2950 redis_node: ClusterNode,
2951 command_name,
2952 *args,
2953 **options,
2954 ):
2955 """
2956 Send a command and parse the response
2957 """
2958
2959 await connection.send_command(*args)
2960 output = await redis_node.parse_response(connection, command_name, **options)
2961
2962 if command_name in self.UNWATCH_COMMANDS:
2963 self._watching = False
2964 return output
2965
2966 async def _reinitialize_on_error(self, error, failure_count):
2967 if hasattr(error, "connection"):
2968 await record_error_count(
2969 server_address=error.connection.host,
2970 server_port=error.connection.port,
2971 network_peer_address=error.connection.host,
2972 network_peer_port=error.connection.port,
2973 error_type=error,
2974 retry_attempts=failure_count,
2975 is_internal=True,
2976 )
2977
2978 if self._watching:
2979 if type(error) in self.SLOT_REDIRECT_ERRORS and self._executing:
2980 raise WatchError("Slot rebalancing occurred while watching keys")
2981
2982 if (
2983 type(error) in self.SLOT_REDIRECT_ERRORS
2984 or type(error) in self.CONNECTION_ERRORS
2985 ):
2986 if self._transaction_connection and self._transaction_node:
2987 # Disconnect and release back to pool
2988 await self._transaction_connection.disconnect()
2989 self._transaction_node.release(self._transaction_connection)
2990 self._transaction_connection = None
2991
2992 self._pipe.cluster_client.reinitialize_counter += 1
2993 if (
2994 self._pipe.cluster_client.reinitialize_steps
2995 and self._pipe.cluster_client.reinitialize_counter
2996 % self._pipe.cluster_client.reinitialize_steps
2997 == 0
2998 ):
2999 await self._pipe.cluster_client.nodes_manager.initialize()
3000 self.reinitialize_counter = 0
3001 else:
3002 if isinstance(error, AskError):
3003 await self._pipe.cluster_client.nodes_manager.move_slot(error)
3004
3005 self._executing = False
3006
3007 async def _raise_first_error(self, responses, stack, start_time):
3008 """
3009 Raise the first exception on the stack
3010 """
3011 for r, cmd in zip(responses, stack):
3012 if isinstance(r, Exception):
3013 self._annotate_exception(r, cmd.position + 1, cmd.args)
3014
3015 await record_operation_duration(
3016 command_name="TRANSACTION",
3017 duration_seconds=time.monotonic() - start_time,
3018 server_address=self._transaction_connection.host,
3019 server_port=self._transaction_connection.port,
3020 db_namespace=str(self._transaction_connection.db),
3021 error=r,
3022 )
3023
3024 raise r
3025
3026 def mset_nonatomic(
3027 self, mapping: Mapping[AnyKeyT, EncodableT]
3028 ) -> "ClusterPipeline":
3029 raise NotImplementedError("Method is not supported in transactional context.")
3030
3031 async def execute(
3032 self, raise_on_error: bool = True, allow_redirections: bool = True
3033 ) -> List[Any]:
3034 stack = self._command_queue
3035 if not stack and (not self._watching or not self._pipeline_slots):
3036 return []
3037
3038 return await self._execute_transaction_with_retries(stack, raise_on_error)
3039
3040 async def _execute_transaction_with_retries(
3041 self, stack: List["PipelineCommand"], raise_on_error: bool
3042 ):
3043 return await self._retry.call_with_retry(
3044 lambda: self._execute_transaction(stack, raise_on_error),
3045 lambda error, failure_count: self._reinitialize_on_error(
3046 error, failure_count
3047 ),
3048 with_failure_count=True,
3049 )
3050
3051 async def _execute_transaction(
3052 self, stack: List["PipelineCommand"], raise_on_error: bool
3053 ):
3054 if len(self._pipeline_slots) > 1:
3055 raise CrossSlotTransactionError(
3056 "All keys involved in a cluster transaction must map to the same slot"
3057 )
3058
3059 self._executing = True
3060
3061 redis_node, connection = self._get_client_and_connection_for_transaction()
3062 # Only disconnect if not watching - disconnecting would lose WATCH state
3063 if not self._watching:
3064 await redis_node.disconnect_if_needed(connection)
3065
3066 stack = chain(
3067 [PipelineCommand(0, "MULTI")],
3068 stack,
3069 [PipelineCommand(0, "EXEC")],
3070 )
3071 commands = [c.args for c in stack if EMPTY_RESPONSE not in c.kwargs]
3072 packed_commands = connection.pack_commands(commands)
3073
3074 # Start timing for observability
3075 start_time = time.monotonic()
3076
3077 await connection.send_packed_command(packed_commands)
3078 errors = []
3079
3080 # parse off the response for MULTI
3081 # NOTE: we need to handle ResponseErrors here and continue
3082 # so that we read all the additional command messages from
3083 # the socket
3084 try:
3085 await redis_node.parse_response(connection, "MULTI")
3086 except ResponseError as e:
3087 self._annotate_exception(e, 0, "MULTI")
3088 errors.append(e)
3089 except self.CONNECTION_ERRORS as cluster_error:
3090 self._annotate_exception(cluster_error, 0, "MULTI")
3091 cluster_error.connection = connection
3092 raise
3093
3094 # and all the other commands
3095 for i, command in enumerate(self._command_queue):
3096 if EMPTY_RESPONSE in command.kwargs:
3097 errors.append((i, command.kwargs[EMPTY_RESPONSE]))
3098 else:
3099 try:
3100 _ = await redis_node.parse_response(connection, "_")
3101 except self.SLOT_REDIRECT_ERRORS as slot_error:
3102 self._annotate_exception(slot_error, i + 1, command.args)
3103 errors.append(slot_error)
3104 except self.CONNECTION_ERRORS as cluster_error:
3105 self._annotate_exception(cluster_error, i + 1, command.args)
3106 cluster_error.connection = connection
3107 raise
3108 except ResponseError as e:
3109 self._annotate_exception(e, i + 1, command.args)
3110 errors.append(e)
3111
3112 response = None
3113 # parse the EXEC.
3114 try:
3115 response = await redis_node.parse_response(connection, "EXEC")
3116 except ExecAbortError:
3117 if errors:
3118 raise errors[0]
3119 raise
3120
3121 self._executing = False
3122
3123 # EXEC clears any watched keys
3124 self._watching = False
3125
3126 if response is None:
3127 raise WatchError("Watched variable changed.")
3128
3129 # put any parse errors into the response
3130 for i, e in errors:
3131 response.insert(i, e)
3132
3133 if len(response) != len(self._command_queue):
3134 raise InvalidPipelineStack(
3135 "Unexpected response length for cluster pipeline EXEC."
3136 " Command stack was {} but response had length {}".format(
3137 [c.args[0] for c in self._command_queue], len(response)
3138 )
3139 )
3140
3141 # find any errors in the response and raise if necessary
3142 if raise_on_error or len(errors) > 0:
3143 await self._raise_first_error(
3144 response,
3145 self._command_queue,
3146 start_time,
3147 )
3148
3149 # We have to run response callbacks manually
3150 data = []
3151 for r, cmd in zip(response, self._command_queue):
3152 if not isinstance(r, Exception):
3153 command_name = cmd.args[0]
3154 if command_name in self._pipe.cluster_client.response_callbacks:
3155 r = self._pipe.cluster_client.response_callbacks[command_name](
3156 r, **cmd.kwargs
3157 )
3158 data.append(r)
3159
3160 await record_operation_duration(
3161 command_name="TRANSACTION",
3162 duration_seconds=time.monotonic() - start_time,
3163 server_address=connection.host,
3164 server_port=connection.port,
3165 db_namespace=str(connection.db),
3166 )
3167
3168 return data
3169
3170 async def reset(self):
3171 self._command_queue = []
3172
3173 try:
3174 # make sure to reset the connection state in the event that we
3175 # were watching something
3176 if self._transaction_connection:
3177 try:
3178 if self._watching:
3179 # call this manually since our unwatch or
3180 # immediate_execute_command methods can call reset()
3181 await self._transaction_connection.send_command("UNWATCH")
3182 await self._transaction_connection.read_response()
3183 except self.CONNECTION_ERRORS:
3184 # disconnect will also remove any previous WATCHes
3185 if self._transaction_connection:
3186 await self._transaction_connection.disconnect()
3187 except asyncio.CancelledError:
3188 # Disconnect so any unread UNWATCH reply does not get
3189 # served to the next caller that takes the connection.
3190 if self._transaction_connection:
3191 await self._transaction_connection.disconnect()
3192 raise
3193 else:
3194 # On the happy path, honor lazy reconnect before release.
3195 await self._transaction_node.disconnect_if_needed(
3196 self._transaction_connection
3197 )
3198 finally:
3199 # Always return the connection to the node's free queue, even on
3200 # cancellation, so cancelled resets do not leak pooled
3201 # connections. Detach the reference before releasing so the
3202 # strategy never holds a pointer to a returned connection.
3203 # ClusterNode.release is synchronous, so no shield is required.
3204 if self._transaction_connection and self._transaction_node:
3205 connection, self._transaction_connection = (
3206 self._transaction_connection,
3207 None,
3208 )
3209 self._transaction_node.release(connection)
3210 # clean up the other instance attributes
3211 self._transaction_connection = None
3212 self._transaction_node = None
3213 self._watching = False
3214 self._explicit_transaction = False
3215 self._pipeline_slots = set()
3216 self._executing = False
3217
3218 def multi(self):
3219 if self._explicit_transaction:
3220 raise RedisError("Cannot issue nested calls to MULTI")
3221 if self._command_queue:
3222 raise RedisError(
3223 "Commands without an initial WATCH have already been issued"
3224 )
3225 self._explicit_transaction = True
3226
3227 async def watch(self, *names):
3228 if self._explicit_transaction:
3229 raise RedisError("Cannot issue a WATCH after a MULTI")
3230
3231 return await self.execute_command("WATCH", *names)
3232
3233 async def unwatch(self):
3234 if self._watching:
3235 return await self.execute_command("UNWATCH")
3236
3237 return True
3238
3239 async def discard(self):
3240 await self.reset()
3241
3242 async def unlink(self, *names):
3243 return self.execute_command("UNLINK", *names)
3244
3245
3246class _ClusterNodePoolAdapter(ConnectionPoolInterface):
3247 """Thin adapter exposing the :class:`ConnectionPoolInterface` that
3248 :class:`PubSub` requires, backed by a :class:`ClusterNode`'s own
3249 connection pool.
3250
3251 Connections are acquired from the node via
3252 :meth:`ClusterNode.acquire_connection` and returned via
3253 :meth:`ClusterNode.release`. :meth:`PubSub.aclose` already
3254 disconnects the connection *before* calling :meth:`release`, so the
3255 connection is returned to the node's free-queue in a disconnected
3256 state — guaranteeing that a subscribed socket is never silently
3257 reused for regular commands.
3258
3259 Methods that do not apply to this adapter (the underlying node's
3260 lifecycle is managed by the cluster, not by individual PubSub
3261 instances) are implemented as no-ops so the adapter remains a valid
3262 :class:`ConnectionPoolInterface`.
3263 """
3264
3265 def __init__(self, node: "ClusterNode") -> None:
3266 self._node = node
3267 self.connection_kwargs = node.connection_kwargs
3268
3269 # -- methods used by PubSub ------------------------------------------------
3270
3271 def get_encoder(self) -> Encoder:
3272 return self._node.get_encoder()
3273
3274 async def get_connection(
3275 self, command_name: Optional[str] = None, *keys: Any, **options: Any
3276 ) -> AbstractConnection:
3277 connection = self._node.acquire_connection()
3278 try:
3279 await connection.connect()
3280 except BaseException:
3281 # connect() may fail mid-handshake (e.g. after the TCP socket
3282 # is established but before AUTH/HELLO completes) leaving the
3283 # connection in a partially-connected state. Disconnect before
3284 # returning it to the node's free queue so it is not reused.
3285 await connection.disconnect()
3286 self._node.release(connection)
3287 raise
3288 return connection
3289
3290 async def release(self, connection: AbstractConnection) -> None:
3291 # PubSub.aclose() disconnects the connection before calling
3292 # release(), so it is safe to put it back in the node's free
3293 # queue – it will reconnect lazily on next use.
3294 await self._node.disconnect_if_needed(connection)
3295 self._node.release(connection)
3296
3297 # -- no-op stubs for the rest of ConnectionPoolInterface -------------------
3298 # The node's connections are shared with regular cluster traffic and its
3299 # lifecycle is managed by RedisCluster / NodesManager, so the adapter must
3300 # not reset, disconnect, retry-configure or re-auth them on behalf of a
3301 # single PubSub instance.
3302
3303 def get_protocol(self):
3304 return self.connection_kwargs.get("protocol", None)
3305
3306 def reset(self) -> None:
3307 pass
3308
3309 async def disconnect(self, inuse_connections: bool = True) -> None:
3310 pass
3311
3312 async def aclose(self) -> None:
3313 pass
3314
3315 def set_retry(self, retry: "Retry") -> None:
3316 pass
3317
3318 async def re_auth_callback(self, token: TokenInterface) -> None:
3319 pass
3320
3321 def get_connection_count(self) -> List[Tuple[int, dict]]:
3322 return []
3323
3324
3325def _unregister_slots_cache_listener(
3326 dispatcher_ref: "weakref.ref[EventDispatcher]",
3327 listener: AsyncEventListenerInterface,
3328 event_type: Type[object],
3329) -> None:
3330 # Module-level finalizer callback. Kept free of strong references to the
3331 # owning ClusterPubSub so attaching it via weakref.finalize does not
3332 # extend the pubsub's lifetime.
3333 dispatcher = dispatcher_ref()
3334 if dispatcher is not None:
3335 dispatcher.unregister_listeners({event_type: [listener]})
3336
3337
3338class ClusterPubSubSlotsCacheListener(AsyncEventListenerInterface):
3339 """
3340 Async listener that forwards AsyncAfterSlotsCacheRefreshEvent to a
3341 ClusterPubSub.
3342
3343 Holds a weak reference to the pubsub so it does not keep the instance
3344 alive. Deterministic cleanup of the dispatcher's strong reference to this
3345 listener is performed by a ``weakref.finalize`` attached to the owning
3346 ClusterPubSub in ``ClusterPubSub.__init__``.
3347 """
3348
3349 def __init__(self, pubsub: "ClusterPubSub") -> None:
3350 self._pubsub_ref: "weakref.ref[ClusterPubSub]" = weakref.ref(pubsub)
3351
3352 async def listen(self, event: object) -> None:
3353 pubsub = self._pubsub_ref()
3354 if pubsub is None:
3355 # Race window between pubsub GC and the finalizer running; safe
3356 # no-op, finalizer will remove this listener shortly.
3357 return
3358 try:
3359 await pubsub.on_slots_changed()
3360 except Exception as e:
3361 # Listeners must not break slots-cache refresh; log and continue so
3362 # a single buggy pubsub cannot starve the rest.
3363 logger.exception(
3364 "pubsub %r raised during slots-cache change: %s: %s",
3365 pubsub,
3366 type(e).__name__,
3367 e,
3368 )
3369
3370
3371class ClusterPubSub(PubSub):
3372 """
3373 Async cluster implementation for pub/sub.
3374
3375 IMPORTANT: before using ClusterPubSub, read about the known limitations
3376 with pubsub in Cluster mode and learn how to workaround them:
3377 https://redis.readthedocs.io/en/stable/clustering.html#known-pubsub-limitations
3378 """
3379
3380 def __init__(
3381 self,
3382 redis_cluster: "RedisCluster",
3383 node: Optional["ClusterNode"] = None,
3384 host: Optional[str] = None,
3385 port: Optional[int] = None,
3386 push_handler_func: Optional[Callable] = None,
3387 event_dispatcher: Optional[EventDispatcher] = None,
3388 **kwargs: Any,
3389 ) -> None:
3390 """
3391 When a pubsub instance is created without specifying a node, a single
3392 node will be transparently chosen for the pubsub connection on the
3393 first command execution. The node will be determined by:
3394 1. Hashing the channel name in the request to find its keyslot
3395 2. Selecting a node that handles the keyslot: If read_from_replicas is
3396 set to true or load_balancing_strategy is set, a replica can be selected.
3397
3398 :param redis_cluster: RedisCluster instance
3399 :param node: ClusterNode to connect to
3400 :param host: Host of the node to connect to
3401 :param port: Port of the node to connect to
3402 :param push_handler_func: Optional push handler function
3403 :param event_dispatcher: Optional event dispatcher
3404 :param kwargs: Additional keyword arguments
3405 """
3406 self.node = None
3407 self.set_pubsub_node(redis_cluster, node, host, port)
3408
3409 # Borrow the node's own connection pool via an adapter rather than
3410 # creating a second, detached ConnectionPool for pubsub.
3411 if self.node is not None:
3412 connection_pool = _ClusterNodePoolAdapter(self.node)
3413 else:
3414 connection_pool = None
3415
3416 self.cluster = redis_cluster
3417 self.node_pubsub_mapping: Dict[str, PubSub] = {}
3418 # Reverse index: shard channel (normalized) -> owning node.name. Used to
3419 # route sunsubscribe calls and reconcile subscriptions after slot
3420 # migration / failover.
3421 self._shard_channel_to_node: Dict[Any, str] = {}
3422 # Dedicated lock for shard-subscription bookkeeping. Distinct from
3423 # PubSub.self._lock (which serializes wire I/O on the cluster-level
3424 # connection used by aclose / send_command / regular subscribe) so
3425 # that reconciliation cannot starve those unrelated coroutines
3426 # during long per-channel migrations.
3427 self._shard_state_lock: asyncio.Lock = asyncio.Lock()
3428 # Background tasks created by on_slots_changed; kept to prevent GC.
3429 self._reconcile_tasks: Set[asyncio.Task] = set()
3430 self._pubsubs_generator = self._pubsubs_generator()
3431 if event_dispatcher is None:
3432 self._event_dispatcher = EventDispatcher()
3433 else:
3434 self._event_dispatcher = event_dispatcher
3435 super().__init__(
3436 connection_pool=connection_pool,
3437 encoder=redis_cluster.encoder,
3438 push_handler_func=push_handler_func,
3439 event_dispatcher=self._event_dispatcher,
3440 **kwargs,
3441 )
3442 # Subscribe to slots-cache change notifications so shard subscriptions
3443 # can be reconciled automatically after topology refreshes.
3444 nm_dispatcher = redis_cluster.nodes_manager._event_dispatcher
3445 self._slots_cache_listener = ClusterPubSubSlotsCacheListener(self)
3446 nm_dispatcher.register_listeners(
3447 {AsyncAfterSlotsCacheRefreshEvent: [self._slots_cache_listener]}
3448 )
3449 # Deterministic GC-time cleanup so short-lived pubsubs do not leak
3450 # listeners in the dispatcher when no slots-refresh event ever fires.
3451 weakref.finalize(
3452 self,
3453 _unregister_slots_cache_listener,
3454 weakref.ref(nm_dispatcher),
3455 self._slots_cache_listener,
3456 AsyncAfterSlotsCacheRefreshEvent,
3457 )
3458
3459 def set_pubsub_node(
3460 self,
3461 cluster: "RedisCluster",
3462 node: Optional["ClusterNode"] = None,
3463 host: Optional[str] = None,
3464 port: Optional[int] = None,
3465 ) -> None:
3466 """
3467 The pubsub node will be set according to the passed node, host and port
3468 When none of the node, host, or port are specified - the node is set
3469 to None and will be determined by the keyslot of the channel in the
3470 first command to be executed.
3471 RedisClusterException will be thrown if the passed node does not exist
3472 in the cluster.
3473 If host is passed without port, or vice versa, a DataError will be
3474 thrown.
3475 """
3476 if node is not None:
3477 # node is passed by the user
3478 self._raise_on_invalid_node(cluster, node, node.host, node.port)
3479 pubsub_node = node
3480 elif host is not None and port is not None:
3481 # host and port passed by the user
3482 node = cluster.get_node(host=host, port=port)
3483 self._raise_on_invalid_node(cluster, node, host, port)
3484 pubsub_node = node
3485 elif host is not None or port is not None:
3486 # only one of host and port is specified
3487 raise DataError("Specify both host and port")
3488 else:
3489 # nothing specified by the user
3490 pubsub_node = None
3491 self.node = pubsub_node
3492
3493 def get_pubsub_node(self) -> Optional["ClusterNode"]:
3494 """
3495 Get the node that is being used as the pubsub connection.
3496
3497 :return: The ClusterNode being used for pubsub, or None if not yet determined
3498 """
3499 return self.node
3500
3501 async def _resubscribe_shard_channels(self) -> None:
3502 # A single node can own multiple slot ranges, so a batched
3503 # ``SSUBSCRIBE`` covering every tracked channel would be rejected by
3504 # Redis with a ``CROSSSLOT`` error. Group by hash slot and emit one
3505 # ``SSUBSCRIBE`` per slot.
3506 by_slot: defaultdict[int, dict] = defaultdict(dict)
3507 for k, v in self.shard_channels.items():
3508 by_slot[key_slot(self.encoder.encode(k))][k] = v
3509 for subscriptions in by_slot.values():
3510 await self._resubscribe(subscriptions, self.ssubscribe)
3511
3512 def _get_node_pubsub(self, node: "ClusterNode") -> PubSub:
3513 """Get or create a PubSub instance for the given node."""
3514 try:
3515 return self.node_pubsub_mapping[node.name]
3516 except KeyError:
3517 pubsub = PubSub(
3518 connection_pool=_ClusterNodePoolAdapter(node),
3519 encoder=self.cluster.encoder,
3520 push_handler_func=self.push_handler_func,
3521 event_dispatcher=self._event_dispatcher,
3522 )
3523 # Replay shard subscriptions on reconnect with slot-aware grouping
3524 # so that channels spanning multiple slots owned by this node do
3525 # not trigger a CROSSSLOT error.
3526 pubsub._resubscribe_shard_channels = MethodType(
3527 ClusterPubSub._resubscribe_shard_channels, pubsub
3528 )
3529 self.node_pubsub_mapping[node.name] = pubsub
3530 return pubsub
3531
3532 def _find_node_name_for_pubsub(self, pubsub: PubSub) -> Optional[str]:
3533 for name, candidate in self.node_pubsub_mapping.items():
3534 if candidate is pubsub:
3535 return name
3536 return None
3537
3538 async def _sharded_message_generator(
3539 self, timeout: float = 0.0
3540 ) -> Tuple[Optional[PubSub], Optional[Dict[str, Any]]]:
3541 """Generate messages from shard channels across all nodes."""
3542 for _ in range(len(self.node_pubsub_mapping)):
3543 pubsub = next(self._pubsubs_generator)
3544 # Don't pass ignore_subscribe_messages here - let get_sharded_message
3545 # handle the filtering after processing subscription state changes
3546 message = await pubsub.get_message(
3547 ignore_subscribe_messages=False, timeout=timeout
3548 )
3549 if message is not None:
3550 return pubsub, message
3551 return None, None
3552
3553 def _pubsubs_generator(self) -> Generator[PubSub, None, None]:
3554 """Generator that yields PubSub instances in round-robin fashion."""
3555 while True:
3556 current_nodes = list(self.node_pubsub_mapping.values())
3557 if not current_nodes:
3558 return # Avoid infinite loop when no subscriptions exist
3559 yield from current_nodes
3560
3561 async def get_sharded_message(
3562 self,
3563 ignore_subscribe_messages: bool = False,
3564 timeout: float = 0.0,
3565 target_node: Optional["ClusterNode"] = None,
3566 ) -> Optional[Dict[str, Any]]:
3567 """
3568 Get a message from shard channels.
3569
3570 :param ignore_subscribe_messages: Whether to ignore subscribe messages
3571 :param timeout: Timeout for message retrieval
3572 :param target_node: Specific node to get message from
3573 :return: Message dictionary or None
3574 """
3575 pubsub: Optional[PubSub]
3576 if target_node:
3577 pubsub = self.node_pubsub_mapping.get(target_node.name)
3578 if pubsub:
3579 # Don't pass ignore_subscribe_messages here - let get_sharded_message
3580 # handle the filtering after processing subscription state changes
3581 message = await pubsub.get_message(
3582 ignore_subscribe_messages=False, timeout=timeout
3583 )
3584 else:
3585 message = None
3586 else:
3587 pubsub, message = await self._sharded_message_generator(timeout=timeout)
3588
3589 if message is None:
3590 return None
3591 # Only sunsubscribe mutates cluster-level shard state; bypassing the
3592 # lock on the data-message hot path keeps smessage delivery from
3593 # competing with the reconciliation task for _shard_state_lock.
3594 if str_if_bytes(message["type"]) == "sunsubscribe":
3595 # Serialize state mutation against reinitialize_shard_subscriptions
3596 # (background task). The blocking get_message above intentionally
3597 # runs outside the lock so reconciliation is not stalled by long
3598 # polls.
3599 async with self._shard_state_lock:
3600 if message["channel"] in self.pending_unsubscribe_shard_channels:
3601 # User-initiated sunsubscribe: drop from cluster-level tracking.
3602 self.pending_unsubscribe_shard_channels.remove(message["channel"])
3603 self.shard_channels.pop(message["channel"], None)
3604 self._shard_channel_to_node.pop(message["channel"], None)
3605 # Drop the per-node pubsub that delivered the confirmation once
3606 # it no longer holds any shard subscriptions, regardless of
3607 # whether the sunsubscribe was user-initiated or driven by
3608 # slot-migration reconciliation (_migrate_shard_channel, which
3609 # intentionally does not add the channel to
3610 # pending_unsubscribe_shard_channels). This releases the
3611 # dedicated connection that would otherwise linger.
3612 # Identifying the receiving pubsub directly (rather than via
3613 # the cluster's current slot map) is required after slot
3614 # migration, where the channel's owner is no longer the node
3615 # that received our original SSUBSCRIBE.
3616 if pubsub is not None and not pubsub.subscribed:
3617 name = self._find_node_name_for_pubsub(pubsub)
3618 if name is not None:
3619 try:
3620 await pubsub.aclose()
3621 except Exception:
3622 pass
3623 self.node_pubsub_mapping.pop(name, None)
3624
3625 # Only suppress subscribe/unsubscribe messages, not data messages (smessage)
3626 if str_if_bytes(message["type"]) in ("ssubscribe", "sunsubscribe"):
3627 if self.ignore_subscribe_messages or ignore_subscribe_messages:
3628 return None
3629 return message
3630
3631 async def ssubscribe(
3632 self, *args: ChannelT | Subscription, **kwargs: PubSubHandler
3633 ) -> None:
3634 """
3635 Subscribe to shard channels.
3636
3637 :param args: Channel names or ``Subscription`` objects
3638 :param kwargs: Channel names with handlers
3639 """
3640 s_channels = parse_pubsub_subscriptions(args, kwargs)
3641
3642 # Serialize against reinitialize_shard_subscriptions (background
3643 # task) so the reverse index, shard_channels, and node_pubsub_mapping
3644 # are not mutated concurrently. _migrate_shard_channel below does not
3645 # re-acquire this lock (asyncio.Lock is non-reentrant).
3646 async with self._shard_state_lock:
3647 for s_channel, handler in s_channels.items():
3648 node = self.cluster.get_node_from_key(s_channel)
3649 if not node:
3650 continue
3651 # Lazy re-route: if this channel is already tracked against a
3652 # different node (e.g. after a slot migration), migrate it now
3653 # so the caller's intent is applied on the current owner.
3654 normalized_key = next(iter(self._normalize_keys({s_channel: None})))
3655 old_name = self._shard_channel_to_node.get(normalized_key)
3656 if old_name and old_name != node.name:
3657 # Match PubSub.ssubscribe() dict.update() semantics: the
3658 # caller's newly supplied handler (including None) always
3659 # overrides any previously registered handler.
3660 await self._migrate_shard_channel(
3661 normalized_key,
3662 handler,
3663 old_name,
3664 node,
3665 )
3666 continue
3667 pubsub = self._get_node_pubsub(node)
3668 if handler:
3669 await pubsub.ssubscribe(Subscription(s_channel, handler))
3670 else:
3671 await pubsub.ssubscribe(s_channel)
3672 self.shard_channels.update(pubsub.shard_channels)
3673 self._shard_channel_to_node[normalized_key] = node.name
3674 self.pending_unsubscribe_shard_channels.difference_update(
3675 self._normalize_keys({s_channel: None})
3676 )
3677
3678 async def sunsubscribe(self, *args: Any) -> None:
3679 """
3680 Unsubscribe from shard channels.
3681
3682 :param args: Channel names to unsubscribe from. If empty, unsubscribe from all.
3683 """
3684 if args:
3685 args = list_or_args(args[0], args[1:])
3686 else:
3687 args = list(self.shard_channels.keys())
3688
3689 # Serialize against reinitialize_shard_subscriptions: the reverse
3690 # index and node_pubsub_mapping must not change between the lookup
3691 # and the per-node sunsubscribe call below.
3692 async with self._shard_state_lock:
3693 for s_channel in args:
3694 normalized_key = next(iter(self._normalize_keys({s_channel: None})))
3695 # Route via the reverse index so we unsubscribe on the node
3696 # that actually holds the subscription. After a slot migration
3697 # the cluster's current owner may no longer be that node.
3698 name = self._shard_channel_to_node.get(normalized_key)
3699 if name and name in self.node_pubsub_mapping:
3700 pubsub = self.node_pubsub_mapping[name]
3701 else:
3702 node = self.cluster.get_node_from_key(s_channel)
3703 if not node or node.name not in self.node_pubsub_mapping:
3704 continue
3705 pubsub = self.node_pubsub_mapping[node.name]
3706 await pubsub.sunsubscribe(s_channel)
3707 self.pending_unsubscribe_shard_channels.update(
3708 pubsub.pending_unsubscribe_shard_channels
3709 )
3710
3711 async def reinitialize_shard_subscriptions(self) -> None:
3712 """
3713 Reconcile per-node shard subscriptions against the cluster's current
3714 slot ownership map. For each tracked shard channel whose owning node
3715 has changed (e.g. after CLUSTER SETSLOT / failover), sunsubscribe on
3716 the old node's pubsub and ssubscribe on the new owner's pubsub,
3717 preserving any registered handler.
3718 """
3719 uncovered: list = []
3720 made_progress = False
3721 first_migrate_error: Optional[BaseException] = None
3722 async with self._shard_state_lock:
3723 for channel, handler in list(self.shard_channels.items()):
3724 try:
3725 new_node = self.cluster.get_node_from_key(channel)
3726 except SlotNotCoveredError:
3727 # Slot is transiently uncovered (mid-migration / partial
3728 # topology refresh). Defer this channel so coverable
3729 # siblings still reconcile this pass; we surface the
3730 # error below so the caller (and logs) know not every
3731 # channel was reconciled. Retry happens on the next
3732 # slots-cache change notification.
3733 uncovered.append(channel)
3734 continue
3735 old_name = self._shard_channel_to_node.get(channel)
3736 if old_name == new_node.name:
3737 continue
3738 try:
3739 await self._migrate_shard_channel(
3740 channel, handler, old_name, new_node
3741 )
3742 made_progress = True
3743 except (ConnectionError, TimeoutError, OSError) as e:
3744 # Transient connectivity error while subscribing on the
3745 # new owner (or unsubscribing on the old owner if its
3746 # handler chose to re-raise). Do not abort reconciliation
3747 # for sibling channels: _shard_channel_to_node was not
3748 # advanced for this channel, so the next slots-cache
3749 # change notification will retry it.
3750 logger.warning(
3751 "shard channel %r migration deferred: %s: %s",
3752 channel,
3753 type(e).__name__,
3754 e,
3755 )
3756 if first_migrate_error is None:
3757 first_migrate_error = e
3758 continue
3759 # Garbage-collect per-node pubsubs that no longer hold any
3760 # subscription so their connections are released.
3761 for name, pubsub in list(self.node_pubsub_mapping.items()):
3762 if not pubsub.subscribed:
3763 try:
3764 await pubsub.aclose()
3765 except Exception:
3766 pass
3767 self.node_pubsub_mapping.pop(name, None)
3768 if uncovered:
3769 # Surface the uncovered channels so the caller (and observer
3770 # notification path) knows reconciliation was incomplete. All
3771 # coverable siblings have already been migrated above.
3772 raise SlotNotCoveredError(
3773 f"{len(uncovered)} shard channel(s) left unreconciled; "
3774 f"slot(s) not covered by the cluster: {uncovered!r}"
3775 )
3776 if first_migrate_error is not None and not made_progress:
3777 # Every migration attempted in this pass failed transiently and
3778 # nothing else made progress. Re-raise the first caught error
3779 # (typically the root cause; later failures are often downstream
3780 # symptoms of the same unreachable node) so the task's done-
3781 # callback surfaces a single representative failure through the
3782 # same logger channel used for SlotNotCoveredError. Per-channel
3783 # WARNINGs above preserve the full forensic detail.
3784 raise first_migrate_error
3785
3786 async def _migrate_shard_channel(
3787 self,
3788 channel: Any,
3789 handler: Optional[Callable],
3790 old_name: Optional[str],
3791 new_node: "ClusterNode",
3792 ) -> None:
3793 # Detach from the old per-node pubsub, best-effort: the old node may
3794 # already be unreachable during migration / failover.
3795 if old_name and old_name in self.node_pubsub_mapping:
3796 old_pubsub = self.node_pubsub_mapping[old_name]
3797 try:
3798 await old_pubsub.sunsubscribe(channel)
3799 except (ConnectionError, TimeoutError, OSError):
3800 # redis-py's Connection has already called ``disconnect()``
3801 # before raising (see Connection.read_response /
3802 # send_packed_command with ``disconnect_on_error=True``),
3803 # so ``old_pubsub``'s dedicated socket is gone. Two cases:
3804 #
3805 # 1. The old node is no longer in the cluster topology
3806 # (e.g. removed by failover / topology refresh): no
3807 # reconnect target exists, so ``old_pubsub.subscribed``
3808 # would stay True forever and the end-of-pass GC block
3809 # would skip it. Drop it eagerly so the round-robin
3810 # generator does not keep yielding a dead pubsub that
3811 # produces periodic errors from ``get_sharded_message``.
3812 # 2. The old node is still known (transiently slow /
3813 # unreachable): ``PubSub._execute`` auto-reconnects and
3814 # ``on_connect`` re-subscribes to remaining channels,
3815 # so other subscriptions on the same pubsub recover
3816 # naturally. Leave it alone.
3817 if self.cluster.get_node(node_name=old_name) is None:
3818 try:
3819 await old_pubsub.aclose()
3820 except Exception:
3821 pass
3822 self.node_pubsub_mapping.pop(old_name, None)
3823 # Attach to the new per-node pubsub, preserving the handler. Decode to
3824 # a text key only when we must pass it as a kwarg (handler present).
3825 new_pubsub = self._get_node_pubsub(new_node)
3826 if handler:
3827 await new_pubsub.ssubscribe(Subscription(channel, handler))
3828 else:
3829 await new_pubsub.ssubscribe(channel)
3830 self.shard_channels.update(new_pubsub.shard_channels)
3831 normalized_key = next(iter(self._normalize_keys({channel: None})))
3832 self._shard_channel_to_node[normalized_key] = new_node.name
3833 self.pending_unsubscribe_shard_channels.difference_update(
3834 self._normalize_keys({channel: None})
3835 )
3836
3837 async def on_slots_changed(self) -> None:
3838 # Observer hook invoked by NodesManager after a slots-cache refresh.
3839 # Schedule reconciliation as a separate task so the caller's code
3840 # path (typically MovedError handling in _execute_command) is not
3841 # blocked on the network I/O performed by reinitialize_shard_
3842 # subscriptions. No-op when there are no shard subscriptions to
3843 # reconcile.
3844 if not self.shard_channels:
3845 return
3846 task = asyncio.create_task(self.reinitialize_shard_subscriptions())
3847 self._reconcile_tasks.add(task)
3848 task.add_done_callback(self._reconcile_tasks.discard)
3849 # Consume the task's exception (if any) so Python does not emit a
3850 # "Task exception was never retrieved" warning. reinitialize_shard_
3851 # subscriptions surfaces SlotNotCoveredError when a slot is still
3852 # transiently uncovered; route it through the same logger channel
3853 # as sync ClusterPubSubSlotsCacheListener for consistent observability.
3854 task.add_done_callback(self._log_reconcile_task_exception)
3855
3856 @staticmethod
3857 def _log_reconcile_task_exception(task: "asyncio.Task") -> None:
3858 if task.cancelled():
3859 return
3860 exc = task.exception()
3861 if exc is not None:
3862 logger.error(
3863 "shard subscription reconciliation failed: %r", exc, exc_info=exc
3864 )
3865
3866 def get_redis_connection(self) -> Optional["AbstractConnection"]:
3867 """
3868 Get the Redis connection of the pubsub connected node.
3869
3870 Returns the pubsub's dedicated connection (acquired from its own
3871 connection pool), not from the ClusterNode's connection pool.
3872 This avoids the connection pool resource leak that would occur
3873 if we called node.acquire_connection() without releasing.
3874 """
3875 # Return the pubsub's own dedicated connection, which is acquired
3876 # from self.connection_pool when executing pubsub commands.
3877 # This is safe because it's the connection dedicated to this pubsub
3878 # instance, not a shared pool connection from the ClusterNode.
3879 return self.connection
3880
3881 async def aclose(self) -> None:
3882 """
3883 Disconnect the pubsub connection.
3884 """
3885 # Cancel and gather in-flight reconciliation tasks BEFORE acquiring
3886 # _shard_state_lock. The tasks themselves take that lock inside
3887 # reinitialize_shard_subscriptions; since asyncio.Lock is non-
3888 # reentrant, gathering while holding it would deadlock. Awaiting
3889 # each task with suppressed CancelledError also avoids unhandled-
3890 # exception warnings if the task was created but not yet scheduled.
3891 if self._reconcile_tasks:
3892 tasks = list(self._reconcile_tasks)
3893 for task in tasks:
3894 task.cancel()
3895 await asyncio.gather(*tasks, return_exceptions=True)
3896 # Hold _shard_state_lock across the rest of the teardown so it
3897 # observes the same mutual-exclusion discipline as ssubscribe /
3898 # sunsubscribe / get_sharded_message / reinitialize_shard_
3899 # subscriptions, which all mutate shard_channels,
3900 # _shard_channel_to_node, and node_pubsub_mapping under this lock.
3901 # Without it, super().aclose() rebinds shard_channels and
3902 # pending_unsubscribe_shard_channels in parallel with a concurrent
3903 # user-coroutine mutation that resumes during one of the awaits
3904 # below, silently dropping subscription intent.
3905 async with self._shard_state_lock:
3906 self._reconcile_tasks.clear()
3907 # Close all shard pubsub instances first
3908 for pubsub in self.node_pubsub_mapping.values():
3909 await pubsub.aclose()
3910 # Drop the now-dead per-node pubsubs from the mapping so the
3911 # round-robin in _pubsubs_generator / _sharded_message_generator
3912 # cannot yield them between teardown and re-subscription.
3913 self.node_pubsub_mapping.clear()
3914 # _pubsubs_generator captures node_pubsub_mapping.values() into
3915 # a local list inside ``yield from``; clearing the mapping does
3916 # not reach references already held by that captured snapshot,
3917 # so a generator suspended mid-yield-from would still surface
3918 # the now-aclose()'d per-node pubsubs after re-subscription.
3919 # Recreate it to drop the captured list. type(self) bypasses
3920 # the instance-level self-shadow established at __init__
3921 # (self._pubsubs_generator = self._pubsubs_generator()).
3922 self._pubsubs_generator = type(self)._pubsubs_generator( # type: ignore[method-assign]
3923 self
3924 )
3925 # Let parent handle self.connection disconnect under the lock
3926 # (includes disconnect, release to pool, and clearing
3927 # self.connection)
3928 await super().aclose()
3929 # Clear the reverse index so a reused instance doesn't route
3930 # against stale mappings. super().aclose() has already cleared
3931 # shard_channels.
3932 self._shard_channel_to_node.clear()
3933
3934 def _raise_on_invalid_node(
3935 self,
3936 redis_cluster: "RedisCluster",
3937 node: Optional["ClusterNode"],
3938 host: Optional[str],
3939 port: Optional[int],
3940 ) -> None:
3941 """
3942 Raise a RedisClusterException if the node is None or doesn't exist in
3943 the cluster.
3944 """
3945 if node is None or redis_cluster.get_node(node_name=node.name) is None:
3946 raise RedisClusterException(
3947 f"Node {host}:{port} doesn't exist in the cluster"
3948 )
3949
3950 async def execute_command(self, *args: Any, **kwargs: Any) -> Any:
3951 """
3952 Execute a command on the appropriate cluster node.
3953
3954 Taken code from redis-py and tweaked to make it work within a cluster.
3955 """
3956 # NOTE: don't parse the response in this function -- it could pull a
3957 # legitimate message off the stack if the connection is already
3958 # subscribed to one or more channels
3959
3960 # For shard commands, route to appropriate node
3961 command = args[0].upper() if args else ""
3962 if command in ("SSUBSCRIBE", "SUNSUBSCRIBE", "SPUBLISH"):
3963 if len(args) > 1:
3964 channel = args[1]
3965 node = self.cluster.get_node_from_key(channel)
3966 if node:
3967 pubsub = self._get_node_pubsub(node)
3968 return await pubsub.execute_command(*args, **kwargs)
3969
3970 # For other commands, use the set node or lazily discover one
3971 if self.connection is None:
3972 if self.connection_pool is None:
3973 if len(args) > 1:
3974 # Hash the first channel and get one of the nodes holding
3975 # this slot
3976 channel = args[1]
3977 slot = self.cluster.keyslot(channel)
3978 node = self.cluster.nodes_manager.get_node_from_slot(
3979 slot,
3980 self.cluster.read_from_replicas,
3981 self.cluster.load_balancing_strategy,
3982 )
3983 else:
3984 # Get a random node
3985 node = self.cluster.get_random_node()
3986 self.node = node
3987 self.connection_pool = _ClusterNodePoolAdapter(node)
3988
3989 # Now we have a connection_pool, use parent's execute_command
3990 return await super().execute_command(*args, **kwargs)