Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/redis/cluster.py: 18%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

2109 statements  

1import logging 

2import random 

3import socket 

4import sys 

5import threading 

6import time 

7import weakref 

8from abc import ABC, abstractmethod 

9from collections import OrderedDict, defaultdict 

10from concurrent.futures import Future, ThreadPoolExecutor 

11from contextlib import contextmanager, nullcontext 

12from copy import copy 

13from enum import Enum 

14from functools import partial 

15from itertools import chain 

16from types import MethodType 

17from typing import ( 

18 TYPE_CHECKING, 

19 Any, 

20 Callable, 

21 Dict, 

22 Iterable, 

23 List, 

24 Literal, 

25 Optional, 

26 Set, 

27 Tuple, 

28 Type, 

29 Union, 

30) 

31 

32if TYPE_CHECKING: 

33 from redis.keyspace_notifications import ClusterKeyspaceNotifications 

34 

35from redis import _himport_exec 

36from redis._defaults import DEFAULT_RETRY_BASE, DEFAULT_RETRY_CAP, DEFAULT_RETRY_COUNT 

37from redis._parsers import CommandsParser, Encoder 

38from redis._parsers.helpers import parse_scan 

39from redis.backoff import ExponentialWithJitterBackoff, NoBackoff 

40from redis.cache import CacheConfig, CacheFactory, CacheFactoryInterface, CacheInterface 

41from redis.client import EMPTY_RESPONSE, CaseInsensitiveDict, PubSub, Redis 

42from redis.commands import RedisClusterCommands 

43from redis.commands.helpers import list_or_args, parse_pubsub_subscriptions 

44from redis.commands.metadata import ( 

45 _DEFAULT_KEYED_METADATA, 

46 _DEFAULT_KEYLESS_METADATA, 

47 _METADATA_BY_REQUEST_POLICY, 

48 CommandMetadata, 

49 CommandPolicies, 

50 MetadataResolver, 

51 RequestPolicy, 

52 ResponsePolicy, 

53 StaticMetadataResolver, 

54) 

55from redis.commands.policies import PolicyResolver, StaticPolicyResolver 

56from redis.connection import ( 

57 Connection, 

58 ConnectionPool, 

59 parse_url, 

60) 

61from redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot 

62from redis.event import ( 

63 AfterPooledConnectionsInstantiationEvent, 

64 AfterPubSubConnectionInstantiationEvent, 

65 AfterSlotsCacheRefreshEvent, 

66 ClientType, 

67 EventDispatcher, 

68 EventListenerInterface, 

69) 

70from redis.exceptions import ( 

71 AskError, 

72 AuthenticationError, 

73 AuthorizationError, 

74 ClusterDownError, 

75 ClusterError, 

76 ConnectionError, 

77 CrossSlotTransactionError, 

78 DataError, 

79 ExecAbortError, 

80 InvalidPipelineStack, 

81 MaxConnectionsError, 

82 MovedError, 

83 RedisClusterException, 

84 RedisClusterUnreachableError, 

85 RedisError, 

86 ResponseError, 

87 SlotNotCoveredError, 

88 TimeoutError, 

89 TryAgainError, 

90 WatchError, 

91) 

92from redis.himport import HImportRegistry, parse_himport_set_args 

93from redis.lock import Lock 

94from redis.maint_notifications import ( 

95 MaintNotificationsConfig, 

96 OSSMaintNotificationsHandler, 

97) 

98from redis.observability.recorder import ( 

99 record_error_count, 

100 record_operation_duration, 

101) 

102from redis.retry import Retry 

103from redis.typing import ( 

104 ChannelT, 

105 FieldT, 

106 PubSubHandler, 

107 Subscription, 

108) 

109from redis.utils import ( 

110 check_protocol_version, 

111 deprecated_args, 

112 deprecated_function, 

113 dict_merge, 

114 experimental_method, 

115 list_keys_to_dict, 

116 merge_result, 

117 safe_str, 

118 str_if_bytes, 

119 truncate_text, 

120) 

121 

122logger = logging.getLogger(__name__) 

123 

124 

125def is_debug_log_enabled(): 

126 return logger.isEnabledFor(logging.DEBUG) 

127 

128 

129def get_node_name(host: str, port: Union[str, int]) -> str: 

130 return f"{host}:{port}" 

131 

132 

133@deprecated_args( 

134 allowed_args=["redis_node"], 

135 reason="Use get_connection(redis_node) instead", 

136 version="5.3.0", 

137) 

138def get_connection(redis_node: Redis, *args, **options) -> Connection: 

139 return redis_node.connection or redis_node.connection_pool.get_connection() 

140 

141 

142def parse_scan_result(command, res, **options): 

143 cursors = {} 

144 ret = [] 

145 for node_name, response in res.items(): 

146 cursor, r = parse_scan(response, **options) 

147 cursors[node_name] = cursor 

148 ret += r 

149 

150 return cursors, ret 

151 

152 

153def parse_pubsub_numsub(command, res, **options): 

154 numsub_d = OrderedDict() 

155 for numsub_tups in res.values(): 

156 for channel, numsubbed in numsub_tups: 

157 try: 

158 numsub_d[channel] += numsubbed 

159 except KeyError: 

160 numsub_d[channel] = numsubbed 

161 

162 ret_numsub = [(channel, numsub) for channel, numsub in numsub_d.items()] 

163 return ret_numsub 

164 

165 

166def parse_cluster_slots( 

167 resp: Any, **options: Any 

168) -> Dict[Tuple[int, int], Dict[str, Any]]: 

169 current_host = options.get("current_host", "") 

170 

171 def fix_server(*args: Any) -> Tuple[str, Any]: 

172 return str_if_bytes(args[0]) or current_host, args[1] 

173 

174 slots = {} 

175 for slot in resp: 

176 start, end, primary = slot[:3] 

177 replicas = slot[3:] 

178 slots[start, end] = { 

179 "primary": fix_server(*primary), 

180 "replicas": [fix_server(*replica) for replica in replicas], 

181 } 

182 

183 return slots 

184 

185 

186def parse_cluster_shards(resp, **options): 

187 """ 

188 Parse CLUSTER SHARDS response. 

189 """ 

190 if isinstance(resp[0], dict): 

191 return resp 

192 shards = [] 

193 for x in resp: 

194 shard = {"slots": [], "nodes": []} 

195 for i in range(0, len(x[1]), 2): 

196 shard["slots"].append((x[1][i], (x[1][i + 1]))) 

197 nodes = x[3] 

198 for node in nodes: 

199 dict_node = {} 

200 for i in range(0, len(node), 2): 

201 dict_node[node[i]] = node[i + 1] 

202 shard["nodes"].append(dict_node) 

203 shards.append(shard) 

204 

205 return shards 

206 

207 

208def parse_cluster_shards_with_str_keys(resp, **options): 

209 """ 

210 Parse CLUSTER SHARDS with string top-level structural keys. 

211 

212 RESP2 parsing exposes top-level shard keys as ``"slots"``/``"nodes"`` 

213 while node attribute keys keep the connection's decoded/raw form. RESP3 can 

214 return top-level shard dictionaries directly, so normalize only the 

215 structural shard keys and preserve nested node dictionaries as delivered. 

216 """ 

217 if not resp: 

218 return resp 

219 if not isinstance(resp[0], dict): 

220 return parse_cluster_shards(resp, **options) 

221 

222 shards = [] 

223 for shard_resp in resp: 

224 slots = shard_resp.get(b"slots", shard_resp.get("slots", [])) 

225 nodes = shard_resp.get(b"nodes", shard_resp.get("nodes", [])) 

226 shard = { 

227 "slots": [ 

228 tuple(slot) if isinstance(slot, list) else slot for slot in slots 

229 ], 

230 "nodes": [dict(node) if isinstance(node, dict) else node for node in nodes], 

231 } 

232 shards.append(shard) 

233 return shards 

234 

235 

236def parse_cluster_shards_unified(resp, **options): 

237 """ 

238 Parse CLUSTER SHARDS into the approved unified shape. 

239 

240 Top-level shard keys and nested node attribute keys are strings for both 

241 RESP2 and RESP3 wire responses. 

242 """ 

243 if not resp: 

244 return resp 

245 if isinstance(resp[0], dict): 

246 shards = [] 

247 for shard_resp in resp: 

248 slots = shard_resp.get(b"slots", shard_resp.get("slots", [])) 

249 nodes = shard_resp.get(b"nodes", shard_resp.get("nodes", [])) 

250 shard = { 

251 "slots": slots, 

252 "nodes": [ 

253 {str_if_bytes(k): v for k, v in node.items()} 

254 if isinstance(node, dict) 

255 else node 

256 for node in nodes 

257 ], 

258 } 

259 shards.append(shard) 

260 return shards 

261 

262 shards = [] 

263 for x in resp: 

264 shard = {"slots": [], "nodes": []} 

265 for i in range(0, len(x[1]), 2): 

266 shard["slots"].append((x[1][i], x[1][i + 1])) 

267 nodes = x[3] 

268 for node in nodes: 

269 dict_node = {} 

270 for i in range(0, len(node), 2): 

271 dict_node[str_if_bytes(node[i])] = node[i + 1] 

272 shard["nodes"].append(dict_node) 

273 shards.append(shard) 

274 return shards 

275 

276 

277def parse_cluster_myshardid(resp, **options): 

278 """ 

279 Parse CLUSTER MYSHARDID response. 

280 """ 

281 return resp.decode("utf-8") 

282 

283 

284PRIMARY = "primary" 

285REPLICA = "replica" 

286SLOT_ID = "slot-id" 

287 

288REDIS_ALLOWED_KEYS = ( 

289 "connection_class", 

290 "connection_pool", 

291 "connection_pool_class", 

292 "client_name", 

293 "credential_provider", 

294 "db", 

295 "decode_responses", 

296 "encoding", 

297 "encoding_errors", 

298 "host", 

299 "driver_info", 

300 "lib_name", 

301 "lib_version", 

302 "max_connections", 

303 "nodes_flag", 

304 "redis_connect_func", 

305 "password", 

306 "port", 

307 "timeout", 

308 "queue_class", 

309 "retry", 

310 "retry_on_timeout", 

311 "protocol", 

312 "legacy_responses", 

313 "socket_connect_timeout", 

314 "socket_keepalive", 

315 "socket_keepalive_options", 

316 "socket_read_size", 

317 "socket_timeout", 

318 "ssl", 

319 "ssl_ca_certs", 

320 "ssl_ca_data", 

321 "ssl_ca_path", 

322 "ssl_certfile", 

323 "ssl_cert_reqs", 

324 "ssl_include_verify_flags", 

325 "ssl_exclude_verify_flags", 

326 "ssl_keyfile", 

327 "ssl_password", 

328 "ssl_check_hostname", 

329 "unix_socket_path", 

330 "username", 

331 "cache", 

332 "cache_config", 

333 "maint_notifications_config", 

334) 

335KWARGS_DISABLED_KEYS = ("host", "port", "retry") 

336 

337 

338def cleanup_kwargs(**kwargs): 

339 """ 

340 Remove unsupported or disabled keys from kwargs 

341 """ 

342 connection_kwargs = { 

343 k: v 

344 for k, v in kwargs.items() 

345 if k in REDIS_ALLOWED_KEYS and k not in KWARGS_DISABLED_KEYS 

346 } 

347 

348 return connection_kwargs 

349 

350 

351class MaintNotificationsAbstractRedisCluster: 

352 """ 

353 Abstract class for handling maintenance notifications logic. 

354 This class is expected to be used as base class together with RedisCluster. 

355 

356 This class is intended to be used with multiple inheritance! 

357 

358 All logic related to maintenance notifications is encapsulated in this class. 

359 """ 

360 

361 def __init__( 

362 self, 

363 maint_notifications_config: Optional[MaintNotificationsConfig], 

364 **kwargs, 

365 ): 

366 # Initialize maintenance notifications. 

367 # The RESP3 requirement is validated in RedisCluster.__init__ before the 

368 # NodesManager is constructed; this mixin is only ever run from there, so 

369 # the config it receives has already been validated. 

370 is_protocol_supported = check_protocol_version(kwargs.get("protocol"), 3) 

371 

372 if maint_notifications_config is None and is_protocol_supported: 

373 maint_notifications_config = MaintNotificationsConfig() 

374 

375 self.maint_notifications_config = maint_notifications_config 

376 

377 if self.maint_notifications_config and self.maint_notifications_config.enabled: 

378 self._oss_cluster_maint_notifications_handler = ( 

379 OSSMaintNotificationsHandler(self, self.maint_notifications_config) 

380 ) 

381 # Update connection kwargs for all future nodes connections 

382 self._update_connection_kwargs_for_maint_notifications( 

383 self._oss_cluster_maint_notifications_handler 

384 ) 

385 # Update existing nodes connections - they are created as part of the RedisCluster constructor 

386 for node in self.get_nodes(): 

387 if node.redis_connection is None: 

388 continue 

389 node.redis_connection.connection_pool.update_maint_notifications_config( 

390 self.maint_notifications_config, 

391 oss_cluster_maint_notifications_handler=self._oss_cluster_maint_notifications_handler, 

392 ) 

393 else: 

394 self._oss_cluster_maint_notifications_handler = None 

395 

396 def _update_connection_kwargs_for_maint_notifications( 

397 self, oss_cluster_maint_notifications_handler: OSSMaintNotificationsHandler 

398 ): 

399 """ 

400 Update the connection kwargs for all future connections. 

401 """ 

402 self.nodes_manager.connection_kwargs.update( 

403 { 

404 "oss_cluster_maint_notifications_handler": oss_cluster_maint_notifications_handler, 

405 } 

406 ) 

407 

408 

409class AbstractRedisCluster: 

410 RedisClusterRequestTTL = 16 

411 

412 PRIMARIES = "primaries" 

413 REPLICAS = "replicas" 

414 ALL_NODES = "all" 

415 RANDOM = "random" 

416 DEFAULT_NODE = "default-node" 

417 

418 NODE_FLAGS = {PRIMARIES, REPLICAS, ALL_NODES, RANDOM, DEFAULT_NODE} 

419 

420 COMMAND_FLAGS = dict_merge( 

421 list_keys_to_dict( 

422 [ 

423 "ACL CAT", 

424 "ACL DELUSER", 

425 "ACL DRYRUN", 

426 "ACL GENPASS", 

427 "ACL GETUSER", 

428 "ACL HELP", 

429 "ACL LIST", 

430 "ACL LOG", 

431 "ACL LOAD", 

432 "ACL SAVE", 

433 "ACL SETUSER", 

434 "ACL USERS", 

435 "ACL WHOAMI", 

436 "AUTH", 

437 "CLIENT LIST", 

438 "CLIENT SETINFO", 

439 "CLIENT SETNAME", 

440 "CLIENT GETNAME", 

441 "CONFIG SET", 

442 "CONFIG REWRITE", 

443 "CONFIG RESETSTAT", 

444 "TIME", 

445 "PUBSUB CHANNELS", 

446 "PUBSUB NUMPAT", 

447 "PUBSUB NUMSUB", 

448 "PUBSUB SHARDCHANNELS", 

449 "PUBSUB SHARDNUMSUB", 

450 "PING", 

451 "INFO", 

452 "SHUTDOWN", 

453 "KEYS", 

454 "DBSIZE", 

455 "BGSAVE", 

456 "SLOWLOG GET", 

457 "SLOWLOG LEN", 

458 "SLOWLOG RESET", 

459 "WAIT", 

460 "WAITAOF", 

461 "SAVE", 

462 "MEMORY PURGE", 

463 "MEMORY MALLOC-STATS", 

464 "MEMORY STATS", 

465 "LASTSAVE", 

466 "CLIENT TRACKINGINFO", 

467 "CLIENT PAUSE", 

468 "CLIENT UNPAUSE", 

469 "CLIENT UNBLOCK", 

470 "CLIENT ID", 

471 "CLIENT REPLY", 

472 "CLIENT GETREDIR", 

473 "CLIENT INFO", 

474 "CLIENT KILL", 

475 "READONLY", 

476 "CLUSTER INFO", 

477 "CLUSTER MEET", 

478 "CLUSTER MYSHARDID", 

479 "CLUSTER NODES", 

480 "CLUSTER REPLICAS", 

481 "CLUSTER RESET", 

482 "CLUSTER SET-CONFIG-EPOCH", 

483 "CLUSTER SLOTS", 

484 "CLUSTER SHARDS", 

485 "CLUSTER COUNT-FAILURE-REPORTS", 

486 "CLUSTER KEYSLOT", 

487 "COMMAND", 

488 "COMMAND COUNT", 

489 "COMMAND LIST", 

490 "COMMAND GETKEYS", 

491 "CONFIG GET", 

492 "DEBUG", 

493 "RANDOMKEY", 

494 "READONLY", 

495 "READWRITE", 

496 "TIME", 

497 "TFUNCTION LOAD", 

498 "TFUNCTION DELETE", 

499 "TFUNCTION LIST", 

500 "TFCALL", 

501 "TFCALLASYNC", 

502 "LATENCY HISTORY", 

503 "LATENCY LATEST", 

504 "LATENCY RESET", 

505 "MODULE LIST", 

506 "MODULE LOAD", 

507 "MODULE UNLOAD", 

508 "MODULE LOADEX", 

509 ], 

510 DEFAULT_NODE, 

511 ), 

512 list_keys_to_dict( 

513 [ 

514 "FLUSHALL", 

515 "FLUSHDB", 

516 "FUNCTION DELETE", 

517 "FUNCTION FLUSH", 

518 "FUNCTION LIST", 

519 "FUNCTION LOAD", 

520 "FUNCTION RESTORE", 

521 "SCAN", 

522 "SCRIPT EXISTS", 

523 "SCRIPT FLUSH", 

524 "SCRIPT LOAD", 

525 ], 

526 PRIMARIES, 

527 ), 

528 list_keys_to_dict(["FUNCTION DUMP"], RANDOM), 

529 list_keys_to_dict( 

530 [ 

531 "CLUSTER COUNTKEYSINSLOT", 

532 "CLUSTER DELSLOTS", 

533 "CLUSTER DELSLOTSRANGE", 

534 "CLUSTER GETKEYSINSLOT", 

535 "CLUSTER SETSLOT", 

536 ], 

537 SLOT_ID, 

538 ), 

539 ) 

540 

541 SEARCH_COMMANDS = ( 

542 [ 

543 "FT.CREATE", 

544 "FT.SEARCH", 

545 "FT.AGGREGATE", 

546 "FT.EXPLAIN", 

547 "FT.EXPLAINCLI", 

548 "FT,PROFILE", 

549 "FT.ALTER", 

550 "FT.DROPINDEX", 

551 "FT.ALIASADD", 

552 "FT.ALIASUPDATE", 

553 "FT.ALIASDEL", 

554 "FT.ALIASLIST", 

555 "FT.TAGVALS", 

556 "FT.SUGADD", 

557 "FT.SUGGET", 

558 "FT.SUGDEL", 

559 "FT.SUGLEN", 

560 "FT.SYNUPDATE", 

561 "FT.SYNDUMP", 

562 "FT.SPELLCHECK", 

563 "FT.DICTADD", 

564 "FT.DICTDEL", 

565 "FT.DICTDUMP", 

566 "FT.INFO", 

567 "FT._LIST", 

568 "FT.CONFIG", 

569 "FT.ADD", 

570 "FT.DEL", 

571 "FT.DROP", 

572 "FT.GET", 

573 "FT.MGET", 

574 "FT.SYNADD", 

575 ], 

576 ) 

577 

578 CLUSTER_COMMANDS_RESPONSE_CALLBACKS = { 

579 "CLUSTER SLOTS": parse_cluster_slots, 

580 "CLUSTER SHARDS": parse_cluster_shards, 

581 "CLUSTER MYSHARDID": parse_cluster_myshardid, 

582 } 

583 

584 RESULT_CALLBACKS = dict_merge( 

585 list_keys_to_dict(["PUBSUB NUMSUB", "PUBSUB SHARDNUMSUB"], parse_pubsub_numsub), 

586 list_keys_to_dict( 

587 ["PUBSUB NUMPAT"], lambda command, res: sum(list(res.values())) 

588 ), 

589 list_keys_to_dict( 

590 ["KEYS", "PUBSUB CHANNELS", "PUBSUB SHARDCHANNELS"], merge_result 

591 ), 

592 list_keys_to_dict( 

593 [ 

594 "PING", 

595 "CONFIG SET", 

596 "CONFIG REWRITE", 

597 "CONFIG RESETSTAT", 

598 "CLIENT SETNAME", 

599 "BGSAVE", 

600 "SLOWLOG RESET", 

601 "SAVE", 

602 "MEMORY PURGE", 

603 "CLIENT PAUSE", 

604 "CLIENT UNPAUSE", 

605 ], 

606 lambda command, res: all(res.values()) if isinstance(res, dict) else res, 

607 ), 

608 list_keys_to_dict( 

609 ["DBSIZE", "WAIT"], 

610 lambda command, res: sum(res.values()) if isinstance(res, dict) else res, 

611 ), 

612 list_keys_to_dict( 

613 ["CLIENT UNBLOCK"], lambda command, res: 1 if sum(res.values()) > 0 else 0 

614 ), 

615 list_keys_to_dict(["SCAN"], parse_scan_result), 

616 list_keys_to_dict( 

617 ["SCRIPT LOAD"], lambda command, res: list(res.values()).pop() 

618 ), 

619 list_keys_to_dict( 

620 ["SCRIPT EXISTS"], lambda command, res: [all(k) for k in zip(*res.values())] 

621 ), 

622 list_keys_to_dict(["SCRIPT FLUSH"], lambda command, res: all(res.values())), 

623 ) 

624 

625 ERRORS_ALLOW_RETRY = ( 

626 ConnectionError, 

627 TimeoutError, 

628 ClusterDownError, 

629 SlotNotCoveredError, 

630 ) 

631 

632 def replace_default_node(self, target_node: "ClusterNode" = None) -> None: 

633 """Replace the default cluster node. 

634 A random cluster node will be chosen if target_node isn't passed, and primaries 

635 will be prioritized. The default node will not be changed if there are no other 

636 nodes in the cluster. 

637 

638 Args: 

639 target_node (ClusterNode, optional): Target node to replace the default 

640 node. Defaults to None. 

641 """ 

642 if target_node: 

643 self.nodes_manager.default_node = target_node 

644 else: 

645 curr_node = self.get_default_node() 

646 primaries = [node for node in self.get_primaries() if node != curr_node] 

647 if primaries: 

648 # Choose a primary if the cluster contains different primaries 

649 self.nodes_manager.default_node = random.choice(primaries) 

650 else: 

651 # Otherwise, choose a primary if the cluster contains different primaries 

652 replicas = [node for node in self.get_replicas() if node != curr_node] 

653 if replicas: 

654 self.nodes_manager.default_node = random.choice(replicas) 

655 

656 

657class RedisCluster( 

658 AbstractRedisCluster, MaintNotificationsAbstractRedisCluster, RedisClusterCommands 

659): 

660 # Type discrimination marker for @overload self-type pattern 

661 _is_async_client: Literal[False] = False 

662 

663 @classmethod 

664 def from_url(cls, url: str, **kwargs: Any) -> "RedisCluster": 

665 """ 

666 Return a Redis client object configured from the given URL 

667 

668 For example:: 

669 

670 redis://[[username]:[password]]@localhost:6379/0 

671 rediss://[[username]:[password]]@localhost:6379/0 

672 unix://[username@]/path/to/socket.sock?db=0[&password=password] 

673 

674 Three URL schemes are supported: 

675 

676 - `redis://` creates a TCP socket connection. See more at: 

677 <https://www.iana.org/assignments/uri-schemes/prov/redis> 

678 - `rediss://` creates a SSL wrapped TCP socket connection. See more at: 

679 <https://www.iana.org/assignments/uri-schemes/prov/rediss> 

680 - ``unix://``: creates a Unix Domain Socket connection. 

681 

682 The username, password, hostname and path are passed through 

683 urllib.parse.unquote in order to replace any percent-encoded values 

684 with their corresponding characters. Querystring values are decoded 

685 by urllib.parse.parse_qs and are not unquoted again. 

686 

687 There are several ways to specify a database number. The first value 

688 found will be used: 

689 

690 1. A ``db`` querystring option, e.g. redis://localhost?db=0 

691 2. If using the redis:// or rediss:// schemes, the path argument 

692 of the url, e.g. redis://localhost/0 

693 3. A ``db`` keyword argument to this function. 

694 

695 If none of these options are specified, the default db=0 is used. 

696 

697 All querystring options are cast to their appropriate Python types. 

698 Boolean arguments can be specified with string values "True"/"False" 

699 or "Yes"/"No". Values that cannot be properly cast cause a 

700 ``ValueError`` to be raised. Once parsed, the querystring arguments 

701 and keyword arguments are passed to the ``ConnectionPool``'s 

702 class initializer. In the case of conflicting arguments, querystring 

703 arguments always win. 

704 

705 """ 

706 return cls(url=url, **kwargs) 

707 

708 @deprecated_args( 

709 args_to_warn=["read_from_replicas"], 

710 reason="Please configure the 'load_balancing_strategy' instead", 

711 version="5.3.0", 

712 ) 

713 @deprecated_args( 

714 args_to_warn=[ 

715 "cluster_error_retry_attempts", 

716 ], 

717 reason="Please configure the 'retry' object instead", 

718 version="6.0.0", 

719 ) 

720 def __init__( 

721 self, 

722 host: Optional[str] = None, 

723 port: int = 6379, 

724 startup_nodes: Optional[List["ClusterNode"]] = None, 

725 cluster_error_retry_attempts: int = DEFAULT_RETRY_COUNT, 

726 retry: Optional["Retry"] = None, 

727 require_full_coverage: bool = True, 

728 reinitialize_steps: int = 5, 

729 read_from_replicas: bool = False, 

730 load_balancing_strategy: Optional["LoadBalancingStrategy"] = None, 

731 dynamic_startup_nodes: bool = True, 

732 url: Optional[str] = None, 

733 address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None, 

734 cache: Optional[CacheInterface] = None, 

735 cache_config: Optional[CacheConfig] = None, 

736 event_dispatcher: Optional[EventDispatcher] = None, 

737 policy_resolver: Optional[PolicyResolver] = None, 

738 maint_notifications_config: Optional[MaintNotificationsConfig] = None, 

739 metadata_resolver: Optional[MetadataResolver] = None, 

740 **kwargs, 

741 ): 

742 """ 

743 Initialize a new RedisCluster client. 

744 

745 :param startup_nodes: 

746 List of nodes from which initial bootstrapping can be done 

747 :param host: 

748 Can be used to point to a startup node 

749 :param port: 

750 Can be used to point to a startup node 

751 :param require_full_coverage: 

752 When set to False (default value): the client will not require a 

753 full coverage of the slots. However, if not all slots are covered, 

754 and at least one node has 'cluster-require-full-coverage' set to 

755 'yes,' the server will throw a ClusterDownError for some key-based 

756 commands. See - 

757 https://redis.io/topics/cluster-tutorial#redis-cluster-configuration-parameters 

758 When set to True: all slots must be covered to construct the 

759 cluster client. If not all slots are covered, RedisClusterException 

760 will be thrown. 

761 :param read_from_replicas: 

762 @deprecated - please use load_balancing_strategy instead 

763 Enable read from replicas in READONLY mode. You can read possibly 

764 stale data. 

765 When set to true, read commands will be assigned between the 

766 primary and its replications in a Round-Robin manner. 

767 :param load_balancing_strategy: 

768 Enable read from replicas in READONLY mode and defines the load balancing 

769 strategy that will be used for cluster node selection. 

770 The data read from replicas is eventually consistent with the data in primary nodes. 

771 :param dynamic_startup_nodes: 

772 Set the RedisCluster's startup nodes to all of the discovered nodes. 

773 If true (default value), the cluster's discovered nodes will be used to 

774 determine the cluster nodes-slots mapping in the next topology refresh. 

775 It will remove the initial passed startup nodes if their endpoints aren't 

776 listed in the CLUSTER SLOTS output. 

777 If you use dynamic DNS endpoints for startup nodes but CLUSTER SLOTS lists 

778 specific IP addresses, it is best to set it to false. 

779 :param cluster_error_retry_attempts: 

780 @deprecated - Please configure the 'retry' object instead 

781 In case 'retry' object is set - this argument is ignored! 

782 

783 Number of times to retry before raising an error when 

784 :class:`~.TimeoutError` or :class:`~.ConnectionError`, :class:`~.SlotNotCoveredError` or 

785 :class:`~.ClusterDownError` are encountered 

786 :param retry: 

787 A retry object that defines the retry strategy and the number of 

788 retries for the cluster client. 

789 In current implementation for the cluster client (starting form redis-py version 6.0.0) 

790 the retry object is not yet fully utilized, instead it is used just to determine 

791 the number of retries for the cluster client. 

792 In the future releases the retry object will be used to handle the cluster client retries! 

793 :param reinitialize_steps: 

794 Specifies the number of MOVED errors that need to occur before 

795 reinitializing the whole cluster topology. If a MOVED error occurs 

796 and the cluster does not need to be reinitialized on this current 

797 error handling, only the MOVED slot will be patched with the 

798 redirected node. 

799 To reinitialize the cluster on every MOVED error, set 

800 reinitialize_steps to 1. 

801 To avoid reinitializing the cluster on moved errors, set 

802 reinitialize_steps to 0. 

803 :param address_remap: 

804 An optional callable which, when provided with an internal network 

805 address of a node, e.g. a `(host, port)` tuple, will return the address 

806 where the node is reachable. This can be used to map the addresses at 

807 which the nodes _think_ they are, to addresses at which a client may 

808 reach them, such as when they sit behind a proxy. 

809 

810 :param policy_resolver: 

811 Decides the request/response policies each command is routed by - see 

812 `redis.commands.policies.PolicyResolver`. Defaults to a 

813 `StaticPolicyResolver` built for this client, which resolves the command 

814 metadata this library ships. A resolver built from a live `COMMAND` reply is a 

815 snapshot of the server it was read from, so give each client its own rather 

816 than sharing one across clients on different servers. 

817 This is the narrow routing view of `metadata_resolver`, which supersedes it: 

818 prefer `metadata_resolver`, which serves routing and every other 

819 command-metadata consumer from one object. When both are given this one still 

820 decides which nodes a command targets, so that its 7.1.0 behavior does not 

821 move. It does not decide anything the routing view cannot express: replica 

822 safety and client-side-cache eligibility keep coming from `metadata_resolver`, 

823 because a `CommandPolicies` record carries no `is_readonly` flag to answer them 

824 with. 

825 :param metadata_resolver: 

826 Serves the command metadata this client reads - see 

827 `redis.commands.metadata.MetadataResolver`. Routing is derived from it, and it 

828 is handed to every node's client, where it also decides which commands are 

829 eligible for client-side caching. Defaults to a `StaticMetadataResolver` built 

830 for this client, which resolves the command metadata this library ships; the 

831 library never reads `COMMAND` on its own behalf for this, so the default adds no 

832 round trips. Resolvers chain through `with_fallback`, first match wins, so one 

833 placed in front of `StaticMetadataResolver` overrides the commands it carries 

834 while the static records answer for everything else. To decide eligibility and 

835 routing from the connected server, pass a `DynamicMetadataResolver` built from a 

836 live `COMMAND` reply - use it with care, because reading that reply relies on a 

837 class in the private `redis._parsers` package. Note that a server-derived resolver 

838 decides routing here too, and two families of command route worse from the live 

839 reply than from the shipped records. The server tips commands such as `EXISTS` and 

840 `DEL` with the `multi_shard` request policy this client does not yet implement. And 

841 the `movablekeys` reads - `ZINTER`, `ZUNION`, `ZDIFF`, `ZINTERCARD`, `SINTERCARD`, 

842 `XREAD` - report their keys only in their key specs, so the live reply yields 

843 keyless policies that send them to an arbitrary node rather than the one holding 

844 their keys; the shipped records withhold those policies instead, which is what 

845 leaves the client to resolve the keys through `COMMAND GETKEYS`. So pair such a 

846 resolver with an explicit `policy_resolver=StaticPolicyResolver()` to keep routing 

847 on the shipped records. 

848 :param maint_notifications_config: 

849 Configures the nodes connections to support maintenance notifications - see 

850 `redis.maint_notifications.MaintNotificationsConfig` for details. 

851 Only supported with RESP3. 

852 If not provided and protocol is RESP3, the maintenance notifications 

853 will be enabled by default (logic is included in the NodesManager 

854 initialization). 

855 :**kwargs: 

856 Extra arguments that will be sent into Redis instance when created 

857 (See Official redis-py doc for supported kwargs - the only limitation 

858 is that you can't provide 'retry' object as part of kwargs. 

859 [https://github.com/andymccurdy/redis-py/blob/master/redis/client.py]) 

860 Some kwargs are not supported and will raise a 

861 RedisClusterException: 

862 - db (Redis do not support database SELECT in cluster mode) 

863 

864 """ 

865 if startup_nodes is None: 

866 startup_nodes = [] 

867 

868 if "db" in kwargs: 

869 # Argument 'db' is not possible to use in cluster mode 

870 raise RedisClusterException( 

871 "Argument 'db' is not possible to use in cluster mode" 

872 ) 

873 

874 if "retry" in kwargs: 

875 # Argument 'retry' is not possible to be used in kwargs when in cluster mode 

876 # the kwargs are set to the lower level connections to the cluster nodes 

877 # and there we provide retry configuration without retries allowed. 

878 # The retries should be handled on cluster client level. 

879 raise RedisClusterException( 

880 "The 'retry' argument cannot be used in kwargs when running in cluster mode." 

881 ) 

882 

883 # Get the startup node/s 

884 from_url = False 

885 if url is not None: 

886 from_url = True 

887 url_options = parse_url(url) 

888 if "path" in url_options: 

889 raise RedisClusterException( 

890 "RedisCluster does not currently support Unix Domain " 

891 "Socket connections" 

892 ) 

893 if "db" in url_options and url_options["db"] != 0: 

894 # Argument 'db' is not possible to use in cluster mode 

895 raise RedisClusterException( 

896 "A ``db`` querystring option can only be 0 in cluster mode" 

897 ) 

898 kwargs.update(url_options) 

899 host = kwargs.get("host") 

900 port = kwargs.get("port", port) 

901 startup_nodes.append(ClusterNode(host, port)) 

902 elif host is not None and port is not None: 

903 startup_nodes.append(ClusterNode(host, port)) 

904 elif len(startup_nodes) == 0: 

905 # No startup node was provided 

906 raise RedisClusterException( 

907 "RedisCluster requires at least one node to discover the " 

908 "cluster. Please provide one of the following:\n" 

909 "1. host and port, for example:\n" 

910 " RedisCluster(host='localhost', port=6379)\n" 

911 "2. list of startup nodes, for example:\n" 

912 " RedisCluster(startup_nodes=[ClusterNode('localhost', 6379)," 

913 " ClusterNode('localhost', 6378)])" 

914 ) 

915 # Update the connection arguments 

916 # Whenever a new connection is established, RedisCluster's on_connect 

917 # method should be run 

918 # If the user passed on_connect function we'll save it and run it 

919 # inside the RedisCluster.on_connect() function 

920 self.user_on_connect_func = kwargs.pop("redis_connect_func", None) 

921 kwargs.update({"redis_connect_func": self.on_connect}) 

922 kwargs = cleanup_kwargs(**kwargs) 

923 if retry: 

924 self.retry = retry 

925 else: 

926 self.retry = Retry( 

927 backoff=ExponentialWithJitterBackoff( 

928 base=DEFAULT_RETRY_BASE, cap=DEFAULT_RETRY_CAP 

929 ), 

930 retries=cluster_error_retry_attempts, 

931 ) 

932 

933 self.encoder = Encoder( 

934 kwargs.get("encoding", "utf-8"), 

935 kwargs.get("encoding_errors", "strict"), 

936 kwargs.get("decode_responses", False), 

937 ) 

938 protocol = kwargs.get("protocol", None) 

939 if (cache_config or cache) and not check_protocol_version(protocol, 3): 

940 raise RedisError("Client caching is only supported with RESP version 3") 

941 

942 if ( 

943 maint_notifications_config 

944 and maint_notifications_config.enabled 

945 and not check_protocol_version(protocol, 3) 

946 ): 

947 raise RedisError( 

948 "Maintenance notifications are only supported with RESP version 3" 

949 ) 

950 if check_protocol_version(protocol, 3) and maint_notifications_config is None: 

951 maint_notifications_config = MaintNotificationsConfig() 

952 

953 # Build the client-level HIMPORT registry once (always empty at construction) 

954 # and share the same object with every node pool, so the fieldset registry is 

955 # shared cluster-wide and runtime himport_prepare mutates one object. It is 

956 # handed to the NodesManager and injected onto each node's pool in 

957 # create_redis_node; it is deliberately NOT forwarded through connection_kwargs, 

958 # so nodes reuse the one shared object rather than each rebuilding their own. 

959 self._himport_registry = HImportRegistry() 

960 

961 self.command_flags = self.__class__.COMMAND_FLAGS.copy() 

962 self.node_flags = self.__class__.NODE_FLAGS.copy() 

963 self.read_from_replicas = read_from_replicas 

964 self.load_balancing_strategy = load_balancing_strategy 

965 self.reinitialize_counter = 0 

966 self.reinitialize_steps = reinitialize_steps 

967 if event_dispatcher is None: 

968 self._event_dispatcher = EventDispatcher() 

969 else: 

970 self._event_dispatcher = event_dispatcher 

971 self.startup_nodes = startup_nodes 

972 

973 # Built here rather than defaulted in the signature, so that each client owns its 

974 # resolver and the memos it accumulates are released with the client. The one object 

975 # is shared with every node's client below, so a cluster resolves command metadata - 

976 # routing and cache eligibility both - from a single source of truth. 

977 if metadata_resolver is None: 

978 self._metadata_resolver: MetadataResolver = StaticMetadataResolver() 

979 else: 

980 self._metadata_resolver = metadata_resolver 

981 

982 self.nodes_manager = NodesManager( 

983 startup_nodes=startup_nodes, 

984 from_url=from_url, 

985 require_full_coverage=require_full_coverage, 

986 dynamic_startup_nodes=dynamic_startup_nodes, 

987 address_remap=address_remap, 

988 cache=cache, 

989 cache_config=cache_config, 

990 metadata_resolver=self._metadata_resolver, 

991 event_dispatcher=self._event_dispatcher, 

992 maint_notifications_config=maint_notifications_config, 

993 himport_registry=self._himport_registry, 

994 **kwargs, 

995 ) 

996 

997 cluster_response_callbacks = dict( 

998 self.__class__.CLUSTER_COMMANDS_RESPONSE_CALLBACKS 

999 ) 

1000 legacy_responses = kwargs.get("legacy_responses", True) 

1001 protocol = kwargs.get("protocol") 

1002 if not legacy_responses: 

1003 cluster_response_callbacks["CLUSTER SHARDS"] = parse_cluster_shards_unified 

1004 elif protocol is None: 

1005 cluster_response_callbacks["CLUSTER SHARDS"] = ( 

1006 parse_cluster_shards_with_str_keys 

1007 ) 

1008 self.cluster_response_callbacks = CaseInsensitiveDict( 

1009 cluster_response_callbacks 

1010 ) 

1011 self.result_callbacks = CaseInsensitiveDict(self.__class__.RESULT_CALLBACKS) 

1012 

1013 # For backward compatibility, mapping from existing policies to new one 

1014 self._command_flags_mapping: dict[str, Union[RequestPolicy, ResponsePolicy]] = { 

1015 self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS, 

1016 self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS, 

1017 self.__class__.ALL_NODES: RequestPolicy.ALL_NODES, 

1018 self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS, 

1019 self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE, 

1020 SLOT_ID: RequestPolicy.DEFAULT_KEYED, 

1021 } 

1022 

1023 self._policies_callback_mapping: dict[ 

1024 Union[RequestPolicy, ResponsePolicy], Callable 

1025 ] = { 

1026 RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [ 

1027 self.get_keyless_target_node(command_name) 

1028 ], 

1029 RequestPolicy.DEFAULT_KEYED: lambda command, 

1030 *args: self.get_nodes_from_slot(command, *args), 

1031 RequestPolicy.DEFAULT_NODE: lambda: [self.get_default_node()], 

1032 RequestPolicy.ALL_SHARDS: self.get_primaries, 

1033 RequestPolicy.ALL_NODES: self.get_nodes, 

1034 RequestPolicy.ALL_REPLICAS: self.get_replicas, 

1035 RequestPolicy.MULTI_SHARD: lambda *args, 

1036 **kwargs: self._split_multi_shard_command(*args, **kwargs), 

1037 RequestPolicy.SPECIAL: self.get_special_nodes, 

1038 ResponsePolicy.DEFAULT_KEYLESS: lambda res: res, 

1039 ResponsePolicy.DEFAULT_KEYED: lambda res: res, 

1040 } 

1041 

1042 # ``policy_resolver`` is the routing view of a metadata resolver, so the two 

1043 # arguments overlap. Resolved by precedence rather than by rejecting the 

1044 # combination, because a user migrating from one to the other will legitimately pass 

1045 # both: an explicit ``policy_resolver`` - the extension point that shipped in 7.1.0 

1046 # - keeps deciding which nodes a command targets, and otherwise those policies are 

1047 # derived from the metadata resolver. 

1048 # 

1049 # The precedence covers the routing view only. Replica safety and cache eligibility 

1050 # are read from ``_metadata_resolver`` either way, because the projection a policy 

1051 # resolver serves drops the flags they are decided from - a ``CommandPolicies`` 

1052 # record has no ``is_readonly``. So "ignores metadata_resolver" below means for the 

1053 # target-node decision, not for the record as a whole. 

1054 if policy_resolver is None: 

1055 self._policy_resolver: PolicyResolver = StaticPolicyResolver( 

1056 metadata_resolver=self._metadata_resolver 

1057 ) 

1058 else: 

1059 self._policy_resolver = policy_resolver 

1060 if metadata_resolver is not None: 

1061 logger.debug( 

1062 "Both policy_resolver and metadata_resolver were given; the nodes a " 

1063 "command targets resolve through policy_resolver and ignore " 

1064 "metadata_resolver. Replica safety and client-side-cache eligibility " 

1065 "still resolve through metadata_resolver." 

1066 ) 

1067 self.commands_parser = CommandsParser(self) 

1068 

1069 # Node where FT.AGGREGATE command is executed. 

1070 self._aggregate_nodes = None 

1071 self._lock = threading.RLock() 

1072 

1073 MaintNotificationsAbstractRedisCluster.__init__( 

1074 self, maint_notifications_config, **kwargs 

1075 ) 

1076 

1077 def __enter__(self): 

1078 return self 

1079 

1080 def __exit__(self, exc_type, exc_value, traceback): 

1081 self.close() 

1082 

1083 def __del__(self): 

1084 try: 

1085 self.close() 

1086 except Exception: 

1087 pass 

1088 

1089 def disconnect_connection_pools(self): 

1090 for node in self.get_nodes(): 

1091 if node.redis_connection: 

1092 try: 

1093 node.redis_connection.connection_pool.disconnect() 

1094 except OSError: 

1095 # Client was already disconnected. do nothing 

1096 pass 

1097 

1098 def on_connect(self, connection): 

1099 """ 

1100 Initialize the connection, authenticate and select a database and send 

1101 READONLY if it is set during object initialization. 

1102 """ 

1103 connection.on_connect() 

1104 

1105 if self.read_from_replicas or self.load_balancing_strategy: 

1106 # Sending READONLY command to server to configure connection as 

1107 # readonly. Since each cluster node may change its server type due 

1108 # to a failover, we should establish a READONLY connection 

1109 # regardless of the server type. If this is a primary connection, 

1110 # READONLY would not affect executing write commands. 

1111 connection.send_command("READONLY") 

1112 if str_if_bytes(connection.read_response()) != "OK": 

1113 raise ConnectionError("READONLY command failed") 

1114 

1115 if self.user_on_connect_func is not None: 

1116 self.user_on_connect_func(connection) 

1117 

1118 def get_redis_connection(self, node: "ClusterNode") -> Redis: 

1119 if not node.redis_connection: 

1120 with self._lock: 

1121 if not node.redis_connection: 

1122 self.nodes_manager.create_redis_connections([node]) 

1123 return node.redis_connection 

1124 

1125 def get_node(self, host=None, port=None, node_name=None): 

1126 return self.nodes_manager.get_node(host, port, node_name) 

1127 

1128 def get_primaries(self): 

1129 return self.nodes_manager.get_nodes_by_server_type(PRIMARY) 

1130 

1131 def get_replicas(self): 

1132 return self.nodes_manager.get_nodes_by_server_type(REPLICA) 

1133 

1134 def get_random_node(self): 

1135 return random.choice(list(self.nodes_manager.nodes_cache.values())) 

1136 

1137 def get_keyless_target_node(self, command_name: str) -> "ClusterNode": 

1138 """ 

1139 Returns the node a keyless command is routed to: a random node when replica reads 

1140 are enabled and the command is safe to serve from a replica, a random primary 

1141 otherwise. 

1142 

1143 A replicas-only ``load_balancing_strategy`` is honored by picking from the replicas 

1144 alone, so a strategy that asks for replicas cannot land on a primary here. The 

1145 strategy is not applied any further than that: the rest of it is an index into one 

1146 shard's node list and a round-robin counter kept per primary name, and a keyless 

1147 command has no shard to index - so the pick is uniform over the eligible nodes. 

1148 

1149 Falls back to the whole node set when the cluster has no replicas to pick from, 

1150 which is every primary. That is also the answer for the two strategies that 

1151 include the primary, and for ``read_from_replicas`` on its own, which is what this 

1152 method has returned for a replica-safe command since 7.1.0. 

1153 """ 

1154 replica_safe = ( 

1155 self.read_from_replicas or self.load_balancing_strategy is not None 

1156 ) and self._is_replica_safe(command_name) 

1157 if replica_safe: 

1158 if self.load_balancing_strategy in _REPLICAS_ONLY_STRATEGIES: 

1159 replicas = self.get_replicas() 

1160 if replicas: 

1161 return random.choice(replicas) 

1162 

1163 return self.get_random_node() 

1164 

1165 return self.get_random_primary_node() 

1166 

1167 @deprecated_function( 

1168 version="8.2.0", 

1169 reason="Use get_keyless_target_node() instead.", 

1170 ) 

1171 def get_random_primary_or_all_nodes(self, command_name: str) -> "ClusterNode": 

1172 """ 

1173 Returns random primary or all nodes depends on READONLY mode. 

1174 

1175 Deprecated alias of :meth:`get_keyless_target_node`. Kept so the name that has 

1176 been public since 7.1.0 keeps working; it answers from the metadata resolver just 

1177 as the new name does. 

1178 """ 

1179 return self.get_keyless_target_node(command_name) 

1180 

1181 def _is_replica_safe(self, command_name: str) -> bool: 

1182 return self._metadata_resolver.is_replica_safe(command_name) 

1183 

1184 def get_nodes(self): 

1185 return list(self.nodes_manager.nodes_cache.values()) 

1186 

1187 def get_node_from_key(self, key, replica=False): 

1188 """ 

1189 Get the node that holds the key's slot. 

1190 If replica set to True but the slot doesn't have any replicas, None is 

1191 returned. 

1192 """ 

1193 slot = self.keyslot(key) 

1194 slot_cache = self.nodes_manager.slots_cache.get(slot) 

1195 if slot_cache is None or len(slot_cache) == 0: 

1196 raise SlotNotCoveredError(f'Slot "{slot}" is not covered by the cluster.') 

1197 if replica and len(self.nodes_manager.slots_cache[slot]) < 2: 

1198 return None 

1199 elif replica: 

1200 node_idx = 1 

1201 else: 

1202 # primary 

1203 node_idx = 0 

1204 

1205 return slot_cache[node_idx] 

1206 

1207 def get_default_node(self): 

1208 """ 

1209 Get the cluster's default node 

1210 """ 

1211 return self.nodes_manager.default_node 

1212 

1213 def get_nodes_from_slot(self, command: str, *args): 

1214 """ 

1215 Returns a list of nodes that hold the specified keys' slots. 

1216 """ 

1217 # get the node that holds the key's slot 

1218 slot = self.determine_slot(*args) 

1219 replica_safe = ( 

1220 self.read_from_replicas or self.load_balancing_strategy is not None 

1221 ) and self._is_replica_safe(command) 

1222 node = self.nodes_manager.get_node_from_slot( 

1223 slot, 

1224 replica_safe, 

1225 self.load_balancing_strategy if replica_safe else None, 

1226 ) 

1227 return [node] 

1228 

1229 def _split_multi_shard_command(self, *args, **kwargs) -> list[dict]: 

1230 """ 

1231 Splits the command with Multi-Shard policy, to the multiple commands 

1232 """ 

1233 keys = self._get_command_keys(*args) 

1234 commands = [] 

1235 

1236 for key in keys: 

1237 commands.append( 

1238 { 

1239 "args": (args[0], key), 

1240 "kwargs": kwargs, 

1241 } 

1242 ) 

1243 

1244 return commands 

1245 

1246 def get_special_nodes(self) -> Optional[list["ClusterNode"]]: 

1247 """ 

1248 Returns a list of nodes for commands with a special policy. 

1249 """ 

1250 if not self._aggregate_nodes: 

1251 raise RedisClusterException( 

1252 "Cannot execute FT.CURSOR commands without FT.AGGREGATE" 

1253 ) 

1254 

1255 return self._aggregate_nodes 

1256 

1257 def get_random_primary_node(self) -> "ClusterNode": 

1258 """ 

1259 Returns a random primary node 

1260 """ 

1261 return random.choice(self.get_primaries()) 

1262 

1263 def _evaluate_all_succeeded(self, res): 

1264 """ 

1265 Evaluate the result of a command with ResponsePolicy.ALL_SUCCEEDED 

1266 """ 

1267 first_successful_response = None 

1268 

1269 if isinstance(res, dict): 

1270 for key, value in res.items(): 

1271 if value: 

1272 if first_successful_response is None: 

1273 first_successful_response = {key: value} 

1274 else: 

1275 return {key: False} 

1276 else: 

1277 for response in res: 

1278 if response: 

1279 if first_successful_response is None: 

1280 # Dynamically resolve type 

1281 first_successful_response = type(response)(response) 

1282 else: 

1283 return type(response)(False) 

1284 

1285 return first_successful_response 

1286 

1287 def set_default_node(self, node): 

1288 """ 

1289 Set the default node of the cluster. 

1290 :param node: 'ClusterNode' 

1291 :return True if the default node was set, else False 

1292 """ 

1293 if node is None or self.get_node(node_name=node.name) is None: 

1294 return False 

1295 self.nodes_manager.default_node = node 

1296 return True 

1297 

1298 def set_retry(self, retry: Retry) -> None: 

1299 self.retry = retry 

1300 

1301 def monitor(self, target_node=None): 

1302 """ 

1303 Returns a Monitor object for the specified target node. 

1304 The default cluster node will be selected if no target node was 

1305 specified. 

1306 Monitor is useful for handling the MONITOR command to the redis server. 

1307 next_command() method returns one command from monitor 

1308 listen() method yields commands from monitor. 

1309 """ 

1310 if target_node is None: 

1311 target_node = self.get_default_node() 

1312 if target_node.redis_connection is None: 

1313 raise RedisClusterException( 

1314 f"Cluster Node {target_node.name} has no redis_connection" 

1315 ) 

1316 return target_node.redis_connection.monitor() 

1317 

1318 def pubsub(self, node=None, host=None, port=None, **kwargs): 

1319 """ 

1320 Allows passing a ClusterNode, or host&port, to get a pubsub instance 

1321 connected to the specified node 

1322 """ 

1323 return ClusterPubSub(self, node=node, host=host, port=port, **kwargs) 

1324 

1325 def keyspace_notifications( 

1326 self, 

1327 key_prefix: Union[str, bytes, None] = None, 

1328 ignore_subscribe_messages: bool = True, 

1329 ) -> "ClusterKeyspaceNotifications": 

1330 """ 

1331 Return a :class:`~redis.keyspace_notifications.ClusterKeyspaceNotifications` 

1332 object for subscribing to keyspace and keyevent notifications across 

1333 all primary nodes in the cluster. 

1334 

1335 Note: Keyspace notifications must be enabled on all Redis cluster nodes 

1336 via the ``notify-keyspace-events`` configuration option. 

1337 

1338 Args: 

1339 key_prefix: Optional prefix to filter and strip from keys in 

1340 notifications. 

1341 ignore_subscribe_messages: If True, subscribe/unsubscribe 

1342 confirmations are not returned by 

1343 get_message/listen. 

1344 """ 

1345 from redis.keyspace_notifications import ClusterKeyspaceNotifications 

1346 

1347 return ClusterKeyspaceNotifications( 

1348 self, 

1349 key_prefix=key_prefix, 

1350 ignore_subscribe_messages=ignore_subscribe_messages, 

1351 ) 

1352 

1353 def pipeline(self, transaction=None, shard_hint=None): 

1354 """ 

1355 Cluster impl: 

1356 Pipelines do not work in cluster mode the same way they 

1357 do in normal mode. Create a clone of this object so 

1358 that simulating pipelines will work correctly. Each 

1359 command will be called directly when used and 

1360 when calling execute() will only return the result stack. 

1361 """ 

1362 if shard_hint: 

1363 raise RedisClusterException("shard_hint is deprecated in cluster mode") 

1364 

1365 return ClusterPipeline( 

1366 nodes_manager=self.nodes_manager, 

1367 commands_parser=self.commands_parser, 

1368 startup_nodes=self.nodes_manager.startup_nodes, 

1369 result_callbacks=self.result_callbacks, 

1370 cluster_response_callbacks=self.cluster_response_callbacks, 

1371 read_from_replicas=self.read_from_replicas, 

1372 load_balancing_strategy=self.load_balancing_strategy, 

1373 reinitialize_steps=self.reinitialize_steps, 

1374 retry=self.retry, 

1375 lock=self._lock, 

1376 transaction=transaction, 

1377 # Routing must not change just because the commands go through a pipeline, so the 

1378 # pipeline resolves through the same objects the client does. 

1379 policy_resolver=self._policy_resolver, 

1380 metadata_resolver=self._metadata_resolver, 

1381 event_dispatcher=self._event_dispatcher, 

1382 ) 

1383 

1384 def lock( 

1385 self, 

1386 name, 

1387 timeout=None, 

1388 sleep=0.1, 

1389 blocking=True, 

1390 blocking_timeout=None, 

1391 lock_class=None, 

1392 thread_local=True, 

1393 raise_on_release_error: bool = True, 

1394 ): 

1395 """ 

1396 Return a new Lock object using key ``name`` that mimics 

1397 the behavior of threading.Lock. 

1398 

1399 If specified, ``timeout`` indicates a maximum life for the lock. 

1400 By default, it will remain locked until release() is called. 

1401 

1402 ``sleep`` indicates the amount of time to sleep per loop iteration 

1403 when the lock is in blocking mode and another client is currently 

1404 holding the lock. 

1405 

1406 ``blocking`` indicates whether calling ``acquire`` should block until 

1407 the lock has been acquired or to fail immediately, causing ``acquire`` 

1408 to return False and the lock not being acquired. Defaults to True. 

1409 Note this value can be overridden by passing a ``blocking`` 

1410 argument to ``acquire``. 

1411 

1412 ``blocking_timeout`` indicates the maximum amount of time in seconds to 

1413 spend trying to acquire the lock. A value of ``None`` indicates 

1414 continue trying forever. ``blocking_timeout`` can be specified as a 

1415 float or integer, both representing the number of seconds to wait. 

1416 

1417 ``lock_class`` forces the specified lock implementation. Note that as 

1418 of redis-py 3.0, the only lock class we implement is ``Lock`` (which is 

1419 a Lua-based lock). So, it's unlikely you'll need this parameter, unless 

1420 you have created your own custom lock class. 

1421 

1422 ``thread_local`` indicates whether the lock token is placed in 

1423 thread-local storage. By default, the token is placed in thread local 

1424 storage so that a thread only sees its token, not a token set by 

1425 another thread. Consider the following timeline: 

1426 

1427 time: 0, thread-1 acquires `my-lock`, with a timeout of 5 seconds. 

1428 thread-1 sets the token to "abc" 

1429 time: 1, thread-2 blocks trying to acquire `my-lock` using the 

1430 Lock instance. 

1431 time: 5, thread-1 has not yet completed. redis expires the lock 

1432 key. 

1433 time: 5, thread-2 acquired `my-lock` now that it's available. 

1434 thread-2 sets the token to "xyz" 

1435 time: 6, thread-1 finishes its work and calls release(). if the 

1436 token is *not* stored in thread local storage, then 

1437 thread-1 would see the token value as "xyz" and would be 

1438 able to successfully release the thread-2's lock. 

1439 

1440 ``raise_on_release_error`` indicates whether to raise an exception when 

1441 the lock is no longer owned when exiting the context manager. By default, 

1442 this is True, meaning an exception will be raised. If False, the warning 

1443 will be logged and the exception will be suppressed. 

1444 

1445 In some use cases it's necessary to disable thread local storage. For 

1446 example, if you have code where one thread acquires a lock and passes 

1447 that lock instance to a worker thread to release later. If thread 

1448 local storage isn't disabled in this case, the worker thread won't see 

1449 the token set by the thread that acquired the lock. Our assumption 

1450 is that these cases aren't common and as such default to using 

1451 thread local storage.""" 

1452 if lock_class is None: 

1453 lock_class = Lock 

1454 return lock_class( 

1455 self, 

1456 name, 

1457 timeout=timeout, 

1458 sleep=sleep, 

1459 blocking=blocking, 

1460 blocking_timeout=blocking_timeout, 

1461 thread_local=thread_local, 

1462 raise_on_release_error=raise_on_release_error, 

1463 ) 

1464 

1465 def set_response_callback(self, command, callback): 

1466 """Set a custom Response Callback""" 

1467 self.cluster_response_callbacks[command] = callback 

1468 

1469 def _resolve_command_policies( 

1470 self, *args, target_nodes_specified: bool = False 

1471 ) -> Tuple[str, Union[CommandPolicies, CommandMetadata]]: 

1472 """ 

1473 Resolves the policies a command routes and aggregates by. 

1474 

1475 Returns the name the policies were decided by along with the record, because the 

1476 name a command is known by is not always ``args[0]``: a container command is 

1477 keyed by both of its words, and the flag tables are keyed in upper case. 

1478 

1479 The name is normalized before any branch, so one command answers with one name 

1480 however it got here. The result callbacks are keyed in upper case, so a name that 

1481 kept the caller's spelling on only some paths would fire them on only some paths - 

1482 ``execute_command("dbsize")`` would be summed and ``execute_command("dbsize", 

1483 target_nodes=...)`` would not. 

1484 

1485 First choice is the policy resolver. When it does not know the command, the 

1486 fallbacks are the command's ``COMMAND_FLAGS`` entry and then whether the command 

1487 carries a key. 

1488 """ 

1489 command = args[0].upper() 

1490 if len(args) >= 2 and f"{args[0]} {args[1]}".upper() in self.command_flags: 

1491 command = f"{args[0]} {args[1]}".upper() 

1492 

1493 if target_nodes_specified: 

1494 # The caller named its targets, so nothing is routed from here - and the 

1495 # command's own aggregation must not apply either. A response policy resolved 

1496 # for the whole cluster (``ONE_SUCCEEDED``, an ``AGG_*``) would short-circuit 

1497 # the loop over the nodes the caller picked, or fold their replies into one. 

1498 # Answer with the record that aggregates nothing, and skip the resolver: with 

1499 # the targets given, neither of its answers is used. 

1500 return command, _DEFAULT_KEYLESS_METADATA 

1501 

1502 policies = self._policy_resolver.resolve(args[0].lower()) 

1503 if policies: 

1504 return command, policies 

1505 

1506 command_flag = self.command_flags.get(command) 

1507 if command_flag: 

1508 if command_flag in self._command_flags_mapping: 

1509 return command, _METADATA_BY_REQUEST_POLICY[ 

1510 self._command_flags_mapping[command_flag] 

1511 ] 

1512 return command, _DEFAULT_KEYLESS_METADATA 

1513 

1514 # Unflagged and unresolved, so the command routes by its key. Without a default 

1515 # node the topology is not known yet and there is no slot to route by. 

1516 if not self.get_default_node(): 

1517 return command, _DEFAULT_KEYLESS_METADATA 

1518 

1519 slot = self.determine_slot(*args) 

1520 if slot is None: 

1521 return command, _DEFAULT_KEYLESS_METADATA 

1522 

1523 return command, _DEFAULT_KEYED_METADATA 

1524 

1525 def _determine_nodes( 

1526 self, *args, request_policy: Optional[RequestPolicy] = None, **kwargs 

1527 ) -> List["ClusterNode"]: 

1528 """ 

1529 Determines a nodes the command should be executed on. 

1530 

1531 The caller resolves the command's own policy - see 

1532 ``_resolve_command_policies`` - so the only decision left here is an explicit 

1533 nodes flag, which overrides it. 

1534 """ 

1535 command = args[0].upper() 

1536 if len(args) >= 2 and f"{args[0]} {args[1]}".upper() in self.command_flags: 

1537 command = f"{args[0]} {args[1]}".upper() 

1538 

1539 nodes_flag = kwargs.pop("nodes_flag", None) 

1540 if nodes_flag and self._is_nodes_flag(nodes_flag): 

1541 # nodes flag passed by the user 

1542 if nodes_flag in self._command_flags_mapping: 

1543 request_policy = self._command_flags_mapping[nodes_flag] 

1544 

1545 if request_policy is None: 

1546 raise RedisClusterException( 

1547 f"No targets were found to execute {args} command on" 

1548 ) 

1549 

1550 policy_callback = self._policies_callback_mapping[request_policy] 

1551 

1552 if request_policy == RequestPolicy.DEFAULT_KEYED: 

1553 nodes = policy_callback(command, *args) 

1554 elif request_policy == RequestPolicy.MULTI_SHARD: 

1555 nodes = policy_callback(*args, **kwargs) 

1556 elif request_policy == RequestPolicy.DEFAULT_KEYLESS: 

1557 nodes = policy_callback(args[0]) 

1558 else: 

1559 nodes = policy_callback() 

1560 

1561 if args[0].lower() == "ft.aggregate": 

1562 self._aggregate_nodes = nodes 

1563 

1564 return nodes 

1565 

1566 def _should_reinitialized(self): 

1567 # To reinitialize the cluster on every MOVED error, 

1568 # set reinitialize_steps to 1. 

1569 # To avoid reinitializing the cluster on moved errors, set 

1570 # reinitialize_steps to 0. 

1571 if self.reinitialize_steps == 0: 

1572 return False 

1573 else: 

1574 return self.reinitialize_counter % self.reinitialize_steps == 0 

1575 

1576 def keyslot(self, key): 

1577 """ 

1578 Calculate keyslot for a given key. 

1579 See Keys distribution model in https://redis.io/topics/cluster-spec 

1580 """ 

1581 k = self.encoder.encode(key) 

1582 return key_slot(k) 

1583 

1584 # HIMPORT orchestration. PREPARE/DISCARD/DISCARDALL mutate the one shared 

1585 # HImportRegistry exactly once (every node pool references the same object, so the 

1586 # change is visible cluster-wide and applied lazily per node). SET routes by key 

1587 # slot to the owning primary and reuses that node's standalone himport_set (lazy 

1588 # PREPARE bundled with SET). See ``.agents/himport_client_support_spec.md``. 

1589 

1590 @property 

1591 def himport_registry(self) -> HImportRegistry: 

1592 """The cluster-wide HIMPORT fieldset registry (empty if none was declared). 

1593 

1594 Read-only: the registry is mutated only through the HIMPORT command methods. 

1595 """ 

1596 return self._himport_registry 

1597 

1598 @experimental_method() 

1599 def himport_prepare(self, fieldset_name: str, fields: Iterable[FieldT]) -> bool: 

1600 """Declare an HIMPORT fieldset cluster-wide (shared registry, applied lazily).""" 

1601 self._himport_registry.prepare(fieldset_name, fields) 

1602 return True 

1603 

1604 @experimental_method() 

1605 def himport_discard(self, fieldset_name: str) -> int: 

1606 """Remove an HIMPORT fieldset cluster-wide (shared registry, applied lazily).""" 

1607 return 1 if self._himport_registry.discard(fieldset_name) else 0 

1608 

1609 @experimental_method() 

1610 def himport_discard_all(self) -> int: 

1611 """Remove all HIMPORT fieldsets cluster-wide (shared registry, applied lazily).""" 

1612 return self._himport_registry.discard_all() 

1613 

1614 def _get_command_keys(self, *args): 

1615 """ 

1616 Get the keys in the command. If the command has no keys in it, None is 

1617 returned. 

1618 

1619 NOTE: Due to a bug in redis<7.0, this function does not work properly 

1620 for EVAL or EVALSHA when the `numkeys` arg is 0. 

1621 - issue: https://github.com/redis/redis/issues/9493 

1622 - fix: https://github.com/redis/redis/pull/9733 

1623 

1624 So, don't use this function with EVAL or EVALSHA. 

1625 

1626 Raises: 

1627 RedisClusterException: If the cluster has no default node to resolve 

1628 the keys against, which is the case before the slots cache is 

1629 first populated and after the client is closed. 

1630 """ 

1631 default_node = self.get_default_node() 

1632 if default_node is None: 

1633 # The keys are unknown rather than absent: there is no node to resolve them 

1634 # against. Say that, rather than reporting a missing key the caller did 

1635 # supply, or raising AttributeError from in here. 

1636 # 

1637 # The async stack needs no counterpart: its parser holds the node it 

1638 # was initialized with and never reads the default node. 

1639 raise RedisClusterException( 

1640 "The cluster has no default node to resolve the keys of this " 

1641 "command against. The client may be closed, or not initialized " 

1642 f"yet.\nCommand: {args}" 

1643 ) 

1644 

1645 return self.commands_parser.get_keys(default_node.redis_connection, *args) 

1646 

1647 def determine_slot(self, *args) -> Optional[int]: 

1648 """ 

1649 Figure out what slot to use based on args. 

1650 

1651 Raises a RedisClusterException if there's a missing key and we can't 

1652 determine what slots to map the command to; or, if the keys don't 

1653 all map to the same key slot; or, when the keys have to be resolved 

1654 through ``_get_command_keys``, if the cluster has no default node to 

1655 resolve them against. 

1656 

1657 Returns the slot as an ``int``, which is what the declared return type has always 

1658 promised and what the slot map is keyed by. A command carrying its slot as an 

1659 argument - the ``SLOT_ID`` group - is therefore cast rather than returned verbatim, 

1660 so a caller that spelled the slot as a string gets a usable slot instead of a 

1661 ``KeyError`` from ``get_node_from_slot``. 

1662 """ 

1663 command = args[0] 

1664 if self.command_flags.get(command.upper()) == SLOT_ID: 

1665 # The command contains the slot ID. The flag table is keyed in upper case, so 

1666 # the lookup is normalized - a raw ``execute_command("cluster countkeysinslot", 

1667 # ...)`` names the same command as the spelling the command method sends. 

1668 return int(args[1]) 

1669 

1670 # Get the keys in the command 

1671 

1672 # CLIENT TRACKING is a special case. 

1673 # It doesn't have any keys, it needs to be sent to the provided nodes 

1674 # By default it will be sent to all nodes. 

1675 if command.upper() == "CLIENT TRACKING": 

1676 return None 

1677 

1678 # EVAL and EVALSHA are common enough that it's wasteful to go to the 

1679 # redis server to parse the keys. Besides, there is a bug in redis<7.0 

1680 # where `self._get_command_keys()` fails anyway. So, we special case 

1681 # EVAL/EVALSHA. 

1682 if command.upper() in ("EVAL", "EVALSHA"): 

1683 # command syntax: EVAL "script body" num_keys ... 

1684 if len(args) <= 2: 

1685 raise RedisClusterException(f"Invalid args in command: {args}") 

1686 num_actual_keys = int(args[2]) 

1687 eval_keys = args[3 : 3 + num_actual_keys] 

1688 # if there are 0 keys, that means the script can be run on any node 

1689 # so we can just return a random slot 

1690 if len(eval_keys) == 0: 

1691 return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS) 

1692 keys = eval_keys 

1693 else: 

1694 keys = self._get_command_keys(*args) 

1695 if keys is None or len(keys) == 0: 

1696 # FCALL can call a function with 0 keys, that means the function 

1697 # can be run on any node so we can just return a random slot 

1698 if command.upper() in ("FCALL", "FCALL_RO"): 

1699 return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS) 

1700 raise RedisClusterException( 

1701 "No way to dispatch this command to Redis Cluster. " 

1702 "Missing key.\nYou can execute the command by specifying " 

1703 f"target nodes.\nCommand: {args}" 

1704 ) 

1705 

1706 # single key command 

1707 if len(keys) == 1: 

1708 return self.keyslot(keys[0]) 

1709 

1710 # multi-key command; we need to make sure all keys are mapped to 

1711 # the same slot 

1712 slots = {self.keyslot(key) for key in keys} 

1713 if len(slots) != 1: 

1714 raise RedisClusterException( 

1715 f"{command} - all keys must map to the same key slot" 

1716 ) 

1717 

1718 return slots.pop() 

1719 

1720 def get_encoder(self): 

1721 """ 

1722 Get the connections' encoder 

1723 """ 

1724 return self.encoder 

1725 

1726 def get_connection_kwargs(self): 

1727 """ 

1728 Get the connections' key-word arguments 

1729 """ 

1730 return self.nodes_manager.connection_kwargs 

1731 

1732 def _is_nodes_flag(self, target_nodes): 

1733 return isinstance(target_nodes, str) and target_nodes in self.node_flags 

1734 

1735 def _parse_target_nodes(self, target_nodes): 

1736 if isinstance(target_nodes, list): 

1737 nodes = target_nodes 

1738 elif isinstance(target_nodes, ClusterNode): 

1739 # Supports passing a single ClusterNode as a variable 

1740 nodes = [target_nodes] 

1741 elif isinstance(target_nodes, dict): 

1742 # Supports dictionaries of the format {node_name: node}. 

1743 # It enables to execute commands with multi nodes as follows: 

1744 # rc.cluster_save_config(rc.get_primaries()) 

1745 nodes = target_nodes.values() 

1746 else: 

1747 raise TypeError( 

1748 "target_nodes type can be one of the following: " 

1749 "node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES)," 

1750 "ClusterNode, list<ClusterNode>, or dict<any, ClusterNode>. " 

1751 f"The passed type is {type(target_nodes)}" 

1752 ) 

1753 return nodes 

1754 

1755 def execute_command(self, *args, **kwargs): 

1756 return self._internal_execute_command(*args, **kwargs) 

1757 

1758 def _internal_execute_command(self, *args, **kwargs): 

1759 """ 

1760 Wrapper for ERRORS_ALLOW_RETRY error handling. 

1761 

1762 It will try the number of times specified by the retries property from 

1763 config option "self.retry" which defaults to 10 unless manually 

1764 configured. 

1765 

1766 If it reaches the number of times, the command will raise the exception 

1767 

1768 Key argument :target_nodes: can be passed with the following types: 

1769 nodes_flag: PRIMARIES, REPLICAS, ALL_NODES, RANDOM 

1770 ClusterNode 

1771 list<ClusterNode> 

1772 dict<Any, ClusterNode> 

1773 """ 

1774 target_nodes_specified = False 

1775 is_default_node = False 

1776 target_nodes = None 

1777 passed_targets = kwargs.pop("target_nodes", None) 

1778 if ( 

1779 passed_targets is not None 

1780 and not self._is_nodes_flag(passed_targets) 

1781 and not ( 

1782 isinstance(passed_targets, (list, dict, str)) and not passed_targets 

1783 ) 

1784 ): 

1785 target_nodes = self._parse_target_nodes(passed_targets) 

1786 target_nodes_specified = True 

1787 

1788 command, command_policies = self._resolve_command_policies( 

1789 *args, target_nodes_specified=target_nodes_specified 

1790 ) 

1791 

1792 # If an error that allows retrying was thrown, the nodes and slots 

1793 # cache were reinitialized. We will retry executing the command with 

1794 # the updated cluster setup only when the target nodes can be 

1795 # determined again with the new cache tables. Therefore, when target 

1796 # nodes were passed to this function, we cannot retry the command 

1797 # execution since the nodes may not be valid anymore after the tables 

1798 # were reinitialized. So in case of passed target nodes, 

1799 # retry_attempts will be set to 0. 

1800 retry_attempts = 0 if target_nodes_specified else self.retry.get_retries() 

1801 # Add one for the first execution 

1802 execute_attempts = 1 + retry_attempts 

1803 failure_count = 0 

1804 

1805 # Start timing for observability 

1806 start_time = time.monotonic() 

1807 

1808 for _ in range(execute_attempts): 

1809 try: 

1810 res = {} 

1811 if not target_nodes_specified: 

1812 # Determine the nodes to execute the command on 

1813 target_nodes = self._determine_nodes( 

1814 *args, 

1815 request_policy=command_policies.request_policy, 

1816 nodes_flag=passed_targets, 

1817 ) 

1818 

1819 if not target_nodes: 

1820 raise RedisClusterException( 

1821 f"No targets were found to execute {args} command on" 

1822 ) 

1823 if ( 

1824 len(target_nodes) == 1 

1825 and target_nodes[0] == self.get_default_node() 

1826 ): 

1827 is_default_node = True 

1828 for node in target_nodes: 

1829 res[node.name] = self._execute_command(node, *args, **kwargs) 

1830 

1831 if command_policies.response_policy == ResponsePolicy.ONE_SUCCEEDED: 

1832 break 

1833 

1834 # Return the processed result 

1835 # ``command``, not ``args[0]``: the result callbacks are keyed by the name 

1836 # the policies were decided by, so a container command passed as two words 

1837 # - ``execute_command("command", "count")`` - is dispatched as the command 

1838 # that was actually routed. Telemetry deliberately does not follow; see the 

1839 # note on the metric in the retry branch below. 

1840 return self._process_result( 

1841 command, 

1842 res, 

1843 response_policy=command_policies.response_policy, 

1844 **kwargs, 

1845 ) 

1846 except Exception as e: 

1847 if retry_attempts > 0 and type(e) in self.__class__.ERRORS_ALLOW_RETRY: 

1848 if is_default_node: 

1849 # Replace the default cluster node 

1850 self.replace_default_node() 

1851 # The nodes and slots cache were reinitialized. 

1852 # Try again with the new cluster setup. 

1853 retry_attempts -= 1 

1854 failure_count += 1 

1855 

1856 if hasattr(e, "connection"): 

1857 # ``args[0]``, not the resolved ``command``: every other metric in 

1858 # this class - including the per-command one ``_execute_command`` 

1859 # records on success - names the command the caller spelled. Using 

1860 # the routed name only here would report one command under two 

1861 # names depending on whether it was retried. 

1862 self._record_command_metric( 

1863 command_name=args[0], 

1864 duration_seconds=time.monotonic() - start_time, 

1865 connection=e.connection, 

1866 error=e, 

1867 ) 

1868 

1869 self._record_error_metric( 

1870 error=e, 

1871 connection=e.connection, 

1872 retry_attempts=failure_count, 

1873 ) 

1874 continue 

1875 else: 

1876 # raise the exception 

1877 if hasattr(e, "connection"): 

1878 self._record_error_metric( 

1879 error=e, 

1880 connection=e.connection, 

1881 retry_attempts=failure_count, 

1882 is_internal=False, 

1883 ) 

1884 raise e 

1885 

1886 def _himport_reconcile_discards(self, redis_node, connection): 

1887 """Delegate to the shared sync HIMPORT executor.""" 

1888 return _himport_exec.reconcile_discards(redis_node, connection) 

1889 

1890 def _himport_prepare_and_set( 

1891 self, 

1892 redis_node, 

1893 connection, 

1894 key, 

1895 fieldset_name, 

1896 values, 

1897 fieldset, 

1898 asking: bool = False, 

1899 ): 

1900 """Delegate to the shared sync HIMPORT executor.""" 

1901 return _himport_exec.prepare_and_set( 

1902 redis_node, 

1903 connection, 

1904 key, 

1905 fieldset_name, 

1906 values, 

1907 fieldset, 

1908 asking=asking, 

1909 ) 

1910 

1911 def _himport_execute_set( 

1912 self, 

1913 redis_node, 

1914 connection, 

1915 key, 

1916 fieldset_name, 

1917 values, 

1918 asking: bool = False, 

1919 ): 

1920 """Delegate to the shared sync HIMPORT executor.""" 

1921 return _himport_exec.execute_set( 

1922 redis_node, connection, key, fieldset_name, values, asking=asking 

1923 ) 

1924 

1925 def _execute_command(self, target_node, *args, **kwargs): 

1926 """ 

1927 Send a command to a node in the cluster 

1928 """ 

1929 command = args[0] 

1930 redis_node = None 

1931 connection = None 

1932 redirect_addr = None 

1933 asking = False 

1934 moved = False 

1935 ttl = int(self.RedisClusterRequestTTL) 

1936 

1937 # Start timing for observability 

1938 start_time = time.monotonic() 

1939 

1940 while ttl > 0: 

1941 ttl -= 1 

1942 try: 

1943 if asking: 

1944 target_node = self.get_node(node_name=redirect_addr) 

1945 elif moved: 

1946 # MOVED occurred and the slots cache was updated, 

1947 # refresh the target node 

1948 slot = self.determine_slot(*args) 

1949 replica_safe = ( 

1950 self.read_from_replicas 

1951 or self.load_balancing_strategy is not None 

1952 ) and self._is_replica_safe(command) 

1953 target_node = self.nodes_manager.get_node_from_slot( 

1954 slot, 

1955 replica_safe, 

1956 self.load_balancing_strategy if replica_safe else None, 

1957 ) 

1958 moved = False 

1959 

1960 redis_node = self.get_redis_connection(target_node) 

1961 connection = get_connection(redis_node) 

1962 himport_set = parse_himport_set_args(args) 

1963 if asking and himport_set is None: 

1964 connection.send_command("ASKING") 

1965 redis_node.parse_response(connection, "ASKING", **kwargs) 

1966 asking = False 

1967 if himport_set is not None: 

1968 # args == (HIMPORT_SET, key, fieldset_name, *values). A raw 

1969 # ``execute_command`` with too few args falls through to the 

1970 # normal send path below so the server returns its arity error 

1971 # instead of a client-side IndexError. 

1972 # The cluster 

1973 # executor lazily PREPAREs the fieldset on this connection and 

1974 # reconciles deferred DISCARDs, then SETs; it already applies the 

1975 # HIMPORT SET response callback, so it bypasses the cluster callback 

1976 # block below. 

1977 # This per-command branch in the hot dispatch path is deliberate 

1978 # and has no cleaner alternative: this is the only seam where the 

1979 # concrete routed connection is known, and connection-scoped 

1980 # session setup can only happen once that connection is chosen. 

1981 # On an ASK redirect ``asking`` is folded into the SET's own packed 

1982 # write (see the guard above that suppresses the standalone ASKING 

1983 # for HIMPORT SET) so the allowance sits immediately before the SET. 

1984 # Clear ``asking`` first and carry the allowance in a dedicated 

1985 # local: ``_himport_execute_set`` can raise a retriable MOVED/TRYAGAIN 

1986 # mid-exchange, and a stale ``asking`` would shadow the moved-retry 

1987 # branch on the next loop iteration (mirrors the async client). 

1988 key, fieldset_name, values = himport_set 

1989 ask_himport = asking 

1990 asking = False 

1991 response = self._himport_execute_set( 

1992 redis_node, 

1993 connection, 

1994 key, 

1995 fieldset_name, 

1996 values, 

1997 asking=ask_himport, 

1998 ) 

1999 kwargs.pop("keys", None) 

2000 else: 

2001 connection.send_command(*args, **kwargs) 

2002 response = redis_node.parse_response(connection, command, **kwargs) 

2003 

2004 # Remove keys entry, it needs only for cache. 

2005 kwargs.pop("keys", None) 

2006 

2007 if command in self.cluster_response_callbacks: 

2008 response = self.cluster_response_callbacks[command]( 

2009 response, **kwargs 

2010 ) 

2011 

2012 self._record_command_metric( 

2013 command_name=command, 

2014 duration_seconds=time.monotonic() - start_time, 

2015 connection=connection, 

2016 ) 

2017 return response 

2018 except AuthenticationError as e: 

2019 e.connection = connection if connection is not None else target_node 

2020 self._record_command_metric( 

2021 command_name=command, 

2022 duration_seconds=time.monotonic() - start_time, 

2023 connection=e.connection, 

2024 error=e, 

2025 ) 

2026 raise 

2027 except MaxConnectionsError as e: 

2028 # MaxConnectionsError indicates client-side resource exhaustion 

2029 # (too many connections in the pool), not a node failure. 

2030 # Don't treat this as a node failure - just re-raise the error 

2031 # without reinitializing the cluster. 

2032 # The connection in the error is used to report the metrics based on host and port info 

2033 # so we use the target node object which contains the host and port info 

2034 # because we did not get the connection yet 

2035 e.connection = target_node 

2036 self._record_command_metric( 

2037 command_name=command, 

2038 duration_seconds=time.monotonic() - start_time, 

2039 connection=e.connection, 

2040 error=e, 

2041 ) 

2042 raise 

2043 except (ConnectionError, TimeoutError) as e: 

2044 if is_debug_log_enabled(): 

2045 connection_details = ( 

2046 connection.extract_connection_details() 

2047 if connection 

2048 else "no connection" 

2049 ) 

2050 # Log only the command name - argument values can carry 

2051 # secrets or user data. 

2052 args_log_str = safe_str(args[0]) 

2053 logger.debug( 

2054 f"{type(e).__name__} received for command {args_log_str}, on node {target_node.name}, " 

2055 f"and connection: {connection}, {connection_details}, error: {e}" 

2056 ) 

2057 # this is used to report the metrics based on host and port info 

2058 e.connection = connection if connection else target_node 

2059 

2060 # ConnectionError can also be raised if we couldn't get a 

2061 # connection from the pool before timing out, so check that 

2062 # this is an actual connection before attempting to disconnect. 

2063 if connection is not None: 

2064 connection.disconnect() 

2065 

2066 # Instead of setting to None, properly handle the pool 

2067 # Get the pool safely - redis_connection could be set to None 

2068 # by another thread between the check and access 

2069 redis_conn = target_node.redis_connection 

2070 if redis_conn is not None: 

2071 pool = redis_conn.connection_pool 

2072 if pool is not None: 

2073 with pool._lock: 

2074 # take care for the active connections in the pool 

2075 pool.update_active_connections_for_reconnect() 

2076 # disconnect all free connections 

2077 pool.disconnect_free_connections() 

2078 

2079 # Move the failed node to the end of the cached nodes list 

2080 self.nodes_manager.move_node_to_end_of_cached_nodes(target_node.name) 

2081 

2082 # DON'T set redis_connection = None - keep the pool for reuse 

2083 # provide the name of the failed node so we can try it last 

2084 self.nodes_manager.initialize(last_failed_node_name=target_node.name) 

2085 self._record_command_metric( 

2086 command_name=command, 

2087 duration_seconds=time.monotonic() - start_time, 

2088 connection=e.connection, 

2089 error=e, 

2090 ) 

2091 raise e 

2092 except MovedError as e: 

2093 if is_debug_log_enabled(): 

2094 connection_details = ( 

2095 connection.extract_connection_details() 

2096 if connection 

2097 else "no connection" 

2098 ) 

2099 # Log only the command name - argument values can carry 

2100 # secrets or user data. 

2101 args_log_str = safe_str(args[0]) 

2102 logger.debug( 

2103 f"MOVED error received for command {args_log_str}, on node {target_node.name}, " 

2104 f"and connection: {connection}, {connection_details}, error: {e}" 

2105 ) 

2106 # First, we will try to patch the slots/nodes cache with the 

2107 # redirected node output and try again. If MovedError exceeds 

2108 # 'reinitialize_steps' number of times, we will force 

2109 # reinitializing the tables, and then try again. 

2110 # 'reinitialize_steps' counter will increase faster when 

2111 # the same client object is shared between multiple threads. To 

2112 # reduce the frequency you can set this variable in the 

2113 # RedisCluster constructor. 

2114 self.reinitialize_counter += 1 

2115 if self._should_reinitialized(): 

2116 # during this call all connections are closed or marked for disconnect, 

2117 # so we don't need to disconnect the changed node's connections 

2118 self.nodes_manager.initialize( 

2119 additional_startup_nodes_info=[(e.host, e.port)] 

2120 ) 

2121 # Reset the counter 

2122 self.reinitialize_counter = 0 

2123 else: 

2124 self.nodes_manager.move_slot(e) 

2125 moved = True 

2126 self._record_command_metric( 

2127 command_name=command, 

2128 duration_seconds=time.monotonic() - start_time, 

2129 connection=connection, 

2130 error=e, 

2131 ) 

2132 self._record_error_metric( 

2133 error=e, 

2134 connection=connection, 

2135 ) 

2136 except TryAgainError as e: 

2137 if is_debug_log_enabled(): 

2138 connection_details = ( 

2139 connection.extract_connection_details() 

2140 if connection 

2141 else "no connection" 

2142 ) 

2143 # Log only the command name - argument values can carry 

2144 # secrets or user data. 

2145 args_log_str = safe_str(args[0]) 

2146 logger.debug( 

2147 f"TRYAGAIN error received for command {args_log_str}, on node {target_node.name}, " 

2148 f"and connection: {connection}, {connection_details}" 

2149 ) 

2150 if ttl < self.RedisClusterRequestTTL / 2: 

2151 time.sleep(0.05) 

2152 

2153 self._record_command_metric( 

2154 command_name=command, 

2155 duration_seconds=time.monotonic() - start_time, 

2156 connection=connection, 

2157 error=e, 

2158 ) 

2159 self._record_error_metric( 

2160 error=e, 

2161 connection=connection, 

2162 ) 

2163 except AskError as e: 

2164 if is_debug_log_enabled(): 

2165 connection_details = ( 

2166 connection.extract_connection_details() 

2167 if connection 

2168 else "no connection" 

2169 ) 

2170 # Log only the command name - argument values can carry 

2171 # secrets or user data. 

2172 args_log_str = safe_str(args[0]) 

2173 logger.debug( 

2174 f"ASK error received for command {args_log_str}, on node {target_node.name}, " 

2175 f"and connection: {connection}, {connection_details}, error: {e}" 

2176 ) 

2177 redirect_addr = get_node_name(host=e.host, port=e.port) 

2178 asking = True 

2179 

2180 self._record_command_metric( 

2181 command_name=command, 

2182 duration_seconds=time.monotonic() - start_time, 

2183 connection=connection, 

2184 error=e, 

2185 ) 

2186 self._record_error_metric( 

2187 error=e, 

2188 connection=connection, 

2189 ) 

2190 except (ClusterDownError, SlotNotCoveredError) as e: 

2191 # ClusterDownError can occur during a failover and to get 

2192 # self-healed, we will try to reinitialize the cluster layout 

2193 # and retry executing the command 

2194 

2195 # SlotNotCoveredError can occur when the cluster is not fully 

2196 # initialized or can be temporary issue. 

2197 # We will try to reinitialize the cluster topology 

2198 # and retry executing the command 

2199 

2200 time.sleep(0.25) 

2201 self.nodes_manager.initialize() 

2202 

2203 # if we have a connection, use it, otherwise use the target node 

2204 # object which contains the host and port info 

2205 # this is used to report the metrics based on host and port info 

2206 e.connection = connection if connection else target_node 

2207 self._record_command_metric( 

2208 command_name=command, 

2209 duration_seconds=time.monotonic() - start_time, 

2210 connection=e.connection, 

2211 error=e, 

2212 ) 

2213 raise 

2214 except ResponseError as e: 

2215 # this is used to report the metrics based on host and port info 

2216 # ResponseError typically happens after get_connection() succeeds, 

2217 # so connection should be available 

2218 e.connection = connection if connection else target_node 

2219 self._record_command_metric( 

2220 command_name=command, 

2221 duration_seconds=time.monotonic() - start_time, 

2222 connection=e.connection, 

2223 error=e, 

2224 ) 

2225 raise 

2226 except Exception as e: 

2227 if connection: 

2228 connection.disconnect() 

2229 

2230 # if we have a connection, use it, otherwise use the target node 

2231 # object which contains the host and port info 

2232 # this is used to report the metrics based on host and port info 

2233 e.connection = connection if connection else target_node 

2234 self._record_command_metric( 

2235 command_name=command, 

2236 duration_seconds=time.monotonic() - start_time, 

2237 connection=e.connection, 

2238 error=e, 

2239 ) 

2240 raise e 

2241 finally: 

2242 if connection is not None: 

2243 redis_node.connection_pool.release(connection) 

2244 

2245 e = ClusterError("TTL exhausted.") 

2246 # In this case we should have an active connection. 

2247 # If we are here, we have received many MOVED or ASK errors and finally exhausted the TTL. 

2248 # This means that we used an active connection to read from the socket. 

2249 # This is used to report metrics based on the host and port information. 

2250 e.connection = connection 

2251 self._record_command_metric( 

2252 command_name=command, 

2253 duration_seconds=time.monotonic() - start_time, 

2254 connection=connection, 

2255 error=e, 

2256 ) 

2257 raise e 

2258 

2259 def _record_command_metric( 

2260 self, 

2261 command_name: str, 

2262 duration_seconds: float, 

2263 connection: Connection, 

2264 error=None, 

2265 ): 

2266 """ 

2267 Records operation duration metric directly. 

2268 """ 

2269 host = connection.host if connection else "unknown" 

2270 port = connection.port if connection else 0 

2271 db = str(connection.db) if connection and hasattr(connection, "db") else "0" 

2272 

2273 record_operation_duration( 

2274 command_name=command_name, 

2275 duration_seconds=duration_seconds, 

2276 server_address=host, 

2277 server_port=port, 

2278 db_namespace=db, 

2279 error=error, 

2280 ) 

2281 

2282 def _record_error_metric( 

2283 self, 

2284 error: Exception, 

2285 connection: Connection, 

2286 is_internal: bool = True, 

2287 retry_attempts: Optional[int] = None, 

2288 ): 

2289 """ 

2290 Records error count metric directly. 

2291 """ 

2292 record_error_count( 

2293 server_address=connection.host, 

2294 server_port=connection.port, 

2295 network_peer_address=connection.host, 

2296 network_peer_port=connection.port, 

2297 error_type=error, 

2298 retry_attempts=retry_attempts if retry_attempts is not None else 0, 

2299 is_internal=is_internal, 

2300 ) 

2301 

2302 def close(self) -> None: 

2303 try: 

2304 with self._lock: 

2305 if self.nodes_manager: 

2306 self.nodes_manager.close() 

2307 except AttributeError: 

2308 # RedisCluster's __init__ can fail before nodes_manager is set 

2309 pass 

2310 

2311 def _process_result(self, command, res, response_policy: ResponsePolicy, **kwargs): 

2312 """ 

2313 Process the result of the executed command. 

2314 The function would return a dict or a single value. 

2315 

2316 :type command: str 

2317 :type res: dict 

2318 

2319 `res` should be in the following format: 

2320 Dict<node_name, command_result> 

2321 """ 

2322 if command in self.result_callbacks: 

2323 res = self.result_callbacks[command](command, res, **kwargs) 

2324 elif len(res) == 1: 

2325 # When we execute the command on a single node, we can 

2326 # remove the dictionary and return a single response 

2327 res = list(res.values())[0] 

2328 

2329 return self._policies_callback_mapping[response_policy](res) 

2330 

2331 def load_external_module(self, funcname, func): 

2332 """ 

2333 This function can be used to add externally defined redis modules, 

2334 and their namespaces to the redis client. 

2335 

2336 ``funcname`` - A string containing the name of the function to create 

2337 ``func`` - The function, being added to this class. 

2338 """ 

2339 setattr(self, funcname, func) 

2340 

2341 def transaction(self, func, *watches, **kwargs): 

2342 """ 

2343 Convenience method for executing the callable `func` as a transaction 

2344 while watching all keys specified in `watches`. The 'func' callable 

2345 should expect a single argument which is a Pipeline object. 

2346 """ 

2347 shard_hint = kwargs.pop("shard_hint", None) 

2348 value_from_callable = kwargs.pop("value_from_callable", False) 

2349 watch_delay = kwargs.pop("watch_delay", None) 

2350 with self.pipeline(True, shard_hint) as pipe: 

2351 while True: 

2352 try: 

2353 if watches: 

2354 pipe.watch(*watches) 

2355 func_value = func(pipe) 

2356 exec_value = pipe.execute() 

2357 return func_value if value_from_callable else exec_value 

2358 except WatchError: 

2359 if watch_delay is not None and watch_delay > 0: 

2360 time.sleep(watch_delay) 

2361 continue 

2362 

2363 

2364class ClusterNode: 

2365 def __init__(self, host, port, server_type=None, redis_connection=None): 

2366 if host == "localhost": 

2367 host = socket.gethostbyname(host) 

2368 

2369 self.host = host 

2370 self.port = port 

2371 self.name = get_node_name(host, port) 

2372 self.server_type = server_type 

2373 self.redis_connection = redis_connection 

2374 

2375 def __repr__(self): 

2376 return ( 

2377 f"[host={self.host}," 

2378 f"port={self.port}," 

2379 f"name={self.name}," 

2380 f"server_type={self.server_type}," 

2381 f"redis_connection={self.redis_connection}]" 

2382 ) 

2383 

2384 def __eq__(self, obj): 

2385 return isinstance(obj, ClusterNode) and obj.name == self.name 

2386 

2387 def __hash__(self): 

2388 return hash(self.name) 

2389 

2390 

2391class LoadBalancingStrategy(Enum): 

2392 ROUND_ROBIN = "round_robin" 

2393 ROUND_ROBIN_REPLICAS = "round_robin_replicas" 

2394 RANDOM = "random" 

2395 RANDOM_REPLICA = "random_replica" 

2396 

2397 

2398# The strategies that exclude the primary. ``LoadBalancer`` derives the same thing per 

2399# strategy as the ``replicas_only`` bit it passes to its index helpers below, but it can 

2400# only express it as an index into one shard's node list. Named here so the keyless 

2401# routing path - which has no shard, and so no list to index - can ask the same question. 

2402# A strategy added to the enum has to be classified here too. 

2403_REPLICAS_ONLY_STRATEGIES = frozenset( 

2404 { 

2405 LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, 

2406 LoadBalancingStrategy.RANDOM_REPLICA, 

2407 } 

2408) 

2409 

2410 

2411class LoadBalancer: 

2412 """ 

2413 Round-Robin Load Balancing 

2414 """ 

2415 

2416 def __init__(self, start_index: int = 0) -> None: 

2417 self.primary_to_idx: dict[str, int] = {} 

2418 self.start_index: int = start_index 

2419 self._lock: threading.Lock = threading.Lock() 

2420 

2421 def get_server_index( 

2422 self, 

2423 primary: str, 

2424 list_size: int, 

2425 load_balancing_strategy: LoadBalancingStrategy = LoadBalancingStrategy.ROUND_ROBIN, 

2426 ) -> int: 

2427 if load_balancing_strategy == LoadBalancingStrategy.RANDOM_REPLICA: 

2428 return self._get_random_server_index( 

2429 list_size, 

2430 replicas_only=True, 

2431 ) 

2432 elif load_balancing_strategy == LoadBalancingStrategy.RANDOM: 

2433 return self._get_random_server_index( 

2434 list_size, 

2435 replicas_only=False, 

2436 ) 

2437 else: 

2438 return self._get_round_robin_index( 

2439 primary, 

2440 list_size, 

2441 load_balancing_strategy == LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, 

2442 ) 

2443 

2444 def reset(self) -> None: 

2445 with self._lock: 

2446 self.primary_to_idx.clear() 

2447 

2448 def _get_random_server_index(self, list_size: int, replicas_only: bool) -> int: 

2449 return random.randint(1 if replicas_only else 0, list_size - 1) 

2450 

2451 def _get_round_robin_index( 

2452 self, primary: str, list_size: int, replicas_only: bool 

2453 ) -> int: 

2454 with self._lock: 

2455 server_index = self.primary_to_idx.setdefault(primary, self.start_index) 

2456 if replicas_only and server_index == 0: 

2457 # skip the primary node index 

2458 server_index = 1 

2459 # Update the index for the next round 

2460 self.primary_to_idx[primary] = (server_index + 1) % list_size 

2461 return server_index 

2462 

2463 

2464class NodesManager: 

2465 def __init__( 

2466 self, 

2467 startup_nodes: list[ClusterNode], 

2468 from_url=False, 

2469 require_full_coverage=False, 

2470 lock: Optional[threading.RLock] = None, 

2471 dynamic_startup_nodes=True, 

2472 connection_pool_class=ConnectionPool, 

2473 address_remap: Optional[Callable[[Tuple[str, int]], Tuple[str, int]]] = None, 

2474 cache: Optional[CacheInterface] = None, 

2475 cache_config: Optional[CacheConfig] = None, 

2476 cache_factory: Optional[CacheFactoryInterface] = None, 

2477 event_dispatcher: Optional[EventDispatcher] = None, 

2478 maint_notifications_config: Optional[MaintNotificationsConfig] = None, 

2479 himport_registry: HImportRegistry | None = None, 

2480 metadata_resolver: Optional[MetadataResolver] = None, 

2481 **kwargs, 

2482 ): 

2483 # Shared, cluster-wide HIMPORT registry object, injected onto every node's pool 

2484 # in create_redis_node (not forwarded through connection_kwargs, so all nodes 

2485 # reuse the one object rather than rebuilding it per node). 

2486 self.himport_registry = himport_registry 

2487 self.nodes_cache: dict[str, ClusterNode] = {} 

2488 self.slots_cache: dict[int, list[ClusterNode]] = {} 

2489 self.startup_nodes: dict[str, ClusterNode] = {n.name: n for n in startup_nodes} 

2490 self.default_node: Optional[ClusterNode] = None 

2491 self._epoch: int = 0 

2492 self.from_url = from_url 

2493 self._require_full_coverage = require_full_coverage 

2494 self._dynamic_startup_nodes = dynamic_startup_nodes 

2495 self.connection_pool_class = connection_pool_class 

2496 self.address_remap = address_remap 

2497 # Shared, cluster-wide metadata resolver, injected onto every node's client in 

2498 # create_redis_node for the same reason the cache and the HIMPORT registry are: 

2499 # every node must resolve command metadata - and therefore cache eligibility - 

2500 # through the one object the cluster client was configured with. 

2501 self._metadata_resolver = metadata_resolver 

2502 

2503 self._cache: Optional[CacheInterface] = None 

2504 if cache: 

2505 self._cache = cache 

2506 elif cache_factory is not None: 

2507 self._cache = cache_factory.get_cache() 

2508 elif cache_config is not None: 

2509 # Injected here, on a copy, rather than left to the node pools: the cluster hands 

2510 # every node the one cache built below, so each pool sees a ``cache=`` and would 

2511 # set the resolver on the configuration inside it - which is the caller's object, 

2512 # since ``CacheFactory`` holds it by reference. Copying keeps a ``CacheConfig`` 

2513 # reused across clients from picking up whichever resolver was injected last, 

2514 # exactly as ``ConnectionPool.__init__`` does for the standalone client. 

2515 # 

2516 # Only this branch needs it. ``cache=`` and ``cache_factory=`` hand over a whole 

2517 # cache whose configuration the caller owns, and the node pools set the resolver 

2518 # on it in place - the same thing they do for a standalone client given one. 

2519 if metadata_resolver is not None and isinstance(cache_config, CacheConfig): 

2520 cache_config = copy(cache_config) 

2521 cache_config.set_metadata_resolver(metadata_resolver) 

2522 

2523 self._cache = CacheFactory(cache_config).get_cache() 

2524 self.connection_kwargs = kwargs 

2525 self.read_load_balancer = LoadBalancer() 

2526 

2527 # nodes_cache / slots_cache / startup_nodes / default_node are protected by _lock 

2528 if lock is None: 

2529 self._lock = threading.RLock() 

2530 else: 

2531 self._lock = lock 

2532 

2533 # initialize holds _initialization_lock to dedup multiple calls to reinitialize; 

2534 # note that if we hold both _lock and _initialization_lock, we _must_ acquire 

2535 # _initialization_lock first (ie: to have a consistent order) to avoid deadlock. 

2536 # 

2537 # The same ordering rule extends to OSSMaintNotificationsHandler._lock, which 

2538 # is a third lock in this graph: initialize runs a CLUSTER SLOTS round trip 

2539 # while holding _initialization_lock, and the response can carry an SMIGRATED 

2540 # push that is handled inline on that thread and needs the handler's _lock. The 

2541 # full order is therefore 

2542 # _initialization_lock -> OSSMaintNotificationsHandler._lock 

2543 # -> NodesManager._lock / connection pool locks 

2544 # ie: a thread holding the handler's _lock must never wait for 

2545 # _initialization_lock. 

2546 self._initialization_lock: threading.RLock = threading.RLock() 

2547 # Ident of the thread currently running initialize, or None. Written only 

2548 # under _initialization_lock; see the re-entrancy guard in initialize. 

2549 self._initializing_thread_id: Optional[int] = None 

2550 

2551 if event_dispatcher is None: 

2552 self._event_dispatcher = EventDispatcher() 

2553 else: 

2554 self._event_dispatcher = event_dispatcher 

2555 self._credential_provider = self.connection_kwargs.get( 

2556 "credential_provider", None 

2557 ) 

2558 self.maint_notifications_config = maint_notifications_config 

2559 

2560 self.initialize() 

2561 

2562 def get_node( 

2563 self, 

2564 host: Optional[str] = None, 

2565 port: Optional[int] = None, 

2566 node_name: Optional[str] = None, 

2567 ) -> Optional[ClusterNode]: 

2568 """ 

2569 Get the requested node from the cluster's nodes. 

2570 nodes. 

2571 :return: ClusterNode if the node exists, else None 

2572 """ 

2573 if host and port: 

2574 # the user passed host and port 

2575 if host == "localhost": 

2576 host = socket.gethostbyname(host) 

2577 with self._lock: 

2578 return self.nodes_cache.get(get_node_name(host=host, port=port)) 

2579 elif node_name: 

2580 with self._lock: 

2581 return self.nodes_cache.get(node_name) 

2582 else: 

2583 return None 

2584 

2585 def move_slot(self, e: Union[AskError, MovedError]): 

2586 """ 

2587 Update the slot's node with the redirected one 

2588 """ 

2589 node_changed = False 

2590 with self._lock: 

2591 redirected_node = self.get_node(host=e.host, port=e.port) 

2592 if redirected_node is not None: 

2593 # The node already exists 

2594 if redirected_node.server_type is not PRIMARY: 

2595 # Update the node's server type 

2596 redirected_node.server_type = PRIMARY 

2597 else: 

2598 # This is a new node, we will add it to the nodes cache 

2599 redirected_node = ClusterNode(e.host, e.port, PRIMARY) 

2600 self.nodes_cache[redirected_node.name] = redirected_node 

2601 

2602 slot_nodes = self.slots_cache[e.slot_id] 

2603 if redirected_node not in slot_nodes: 

2604 # The new slot owner is a new server, or a server from a different 

2605 # shard. We need to remove all current nodes from the slot's list 

2606 # (including replications) and add just the new node. 

2607 self.slots_cache[e.slot_id] = [redirected_node] 

2608 node_changed = True 

2609 elif redirected_node is not slot_nodes[0]: 

2610 # The MOVED error resulted from a failover, and the new slot owner 

2611 # had previously been a replica. 

2612 old_primary = slot_nodes[0] 

2613 # Update the old primary to be a replica and add it to the end of 

2614 # the slot's node list 

2615 old_primary.server_type = REPLICA 

2616 slot_nodes.append(old_primary) 

2617 # Remove the old replica, which is now a primary, from the slot's 

2618 # node list 

2619 slot_nodes.remove(redirected_node) 

2620 # Override the old primary with the new one 

2621 slot_nodes[0] = redirected_node 

2622 if self.default_node == old_primary: 

2623 # Update the default node with the new primary 

2624 self.default_node = redirected_node 

2625 node_changed = True 

2626 # else: circular MOVED to current primary -> no-op 

2627 # Dispatch outside the lock so listeners can acquire their own locks 

2628 # without risk of deadlock. Skipped on the no-op branch to avoid 

2629 # needless reconciliation walks under MOVED storms. A listener must 

2630 # not break slots-cache refresh; log and continue so a single buggy 

2631 # listener cannot starve the rest. 

2632 if node_changed: 

2633 try: 

2634 self._event_dispatcher.dispatch(AfterSlotsCacheRefreshEvent()) 

2635 except Exception as exc: 

2636 # Don't shadow the method parameter ``e``: ``except as`` binds 

2637 # the listener exception in the function scope and ``del``s 

2638 # the name on block exit (PEP 3134), which would also wipe 

2639 # out the original AskError/MovedError parameter. 

2640 logger.exception( 

2641 "listener raised during slots-cache refresh: %s: %s", 

2642 type(exc).__name__, 

2643 exc, 

2644 ) 

2645 

2646 @deprecated_args( 

2647 args_to_warn=["server_type"], 

2648 reason=( 

2649 "In case you need select some load balancing strategy " 

2650 "that will use replicas, please set it through 'load_balancing_strategy'" 

2651 ), 

2652 version="5.3.0", 

2653 ) 

2654 def get_node_from_slot( 

2655 self, 

2656 slot: int, 

2657 read_from_replicas: bool = False, 

2658 load_balancing_strategy: Optional[LoadBalancingStrategy] = None, 

2659 server_type: Optional[Literal["primary", "replica"]] = None, 

2660 ) -> ClusterNode: 

2661 """ 

2662 Gets a node that servers this hash slot 

2663 """ 

2664 

2665 if read_from_replicas is True and load_balancing_strategy is None: 

2666 load_balancing_strategy = LoadBalancingStrategy.ROUND_ROBIN 

2667 

2668 with self._lock: 

2669 if self.slots_cache.get(slot) is None or len(self.slots_cache[slot]) == 0: 

2670 raise SlotNotCoveredError( 

2671 f'Slot "{slot}" not covered by the cluster. ' 

2672 + f'"require_full_coverage={self._require_full_coverage}"' 

2673 ) 

2674 

2675 if len(self.slots_cache[slot]) > 1 and load_balancing_strategy: 

2676 # get the server index using the strategy defined in load_balancing_strategy 

2677 primary_name = self.slots_cache[slot][0].name 

2678 node_idx = self.read_load_balancer.get_server_index( 

2679 primary_name, len(self.slots_cache[slot]), load_balancing_strategy 

2680 ) 

2681 elif ( 

2682 server_type is None 

2683 or server_type == PRIMARY 

2684 or len(self.slots_cache[slot]) == 1 

2685 ): 

2686 # return a primary 

2687 node_idx = 0 

2688 else: 

2689 # return a replica 

2690 # randomly choose one of the replicas 

2691 node_idx = random.randint(1, len(self.slots_cache[slot]) - 1) 

2692 

2693 return self.slots_cache[slot][node_idx] 

2694 

2695 def get_nodes_by_server_type(self, server_type: Literal["primary", "replica"]): 

2696 """ 

2697 Get all nodes with the specified server type 

2698 :param server_type: 'primary' or 'replica' 

2699 :return: list of ClusterNode 

2700 """ 

2701 with self._lock: 

2702 return [ 

2703 node 

2704 for node in self.nodes_cache.values() 

2705 if node.server_type == server_type 

2706 ] 

2707 

2708 @deprecated_function( 

2709 reason="This method is not used anymore internally. The startup nodes are populated automatically.", 

2710 version="7.0.2", 

2711 ) 

2712 def populate_startup_nodes(self, nodes): 

2713 """ 

2714 Populate all startup nodes and filters out any duplicates 

2715 """ 

2716 with self._lock: 

2717 for n in nodes: 

2718 self.startup_nodes[n.name] = n 

2719 

2720 def move_node_to_end_of_cached_nodes(self, node_name: str) -> None: 

2721 """ 

2722 Move a failing node to the end of startup_nodes and nodes_cache so it's 

2723 tried last during reinitialization and when selecting the default node. 

2724 If the node is not in the respective list, nothing is done. 

2725 """ 

2726 # Move in startup_nodes 

2727 if node_name in self.startup_nodes and len(self.startup_nodes) > 1: 

2728 node = self.startup_nodes.pop(node_name) 

2729 self.startup_nodes[node_name] = node # Re-insert at end 

2730 

2731 # Move in nodes_cache - this affects get_nodes_by_server_type ordering 

2732 # which is used to select the default_node during initialize() 

2733 if node_name in self.nodes_cache and len(self.nodes_cache) > 1: 

2734 node = self.nodes_cache.pop(node_name) 

2735 self.nodes_cache[node_name] = node # Re-insert at end 

2736 

2737 def check_slots_coverage(self, slots_cache): 

2738 # Validate if all slots are covered or if we should try next 

2739 # startup node 

2740 for i in range(0, REDIS_CLUSTER_HASH_SLOTS): 

2741 if i not in slots_cache: 

2742 return False 

2743 return True 

2744 

2745 def create_redis_connections(self, nodes): 

2746 """ 

2747 This function will create a redis connection to all nodes in :nodes: 

2748 """ 

2749 connection_pools = [] 

2750 for node in nodes: 

2751 if node.redis_connection is None: 

2752 node.redis_connection = self.create_redis_node( 

2753 host=node.host, 

2754 port=node.port, 

2755 maint_notifications_config=self.maint_notifications_config, 

2756 **self.connection_kwargs, 

2757 ) 

2758 connection_pools.append(node.redis_connection.connection_pool) 

2759 

2760 self._event_dispatcher.dispatch( 

2761 AfterPooledConnectionsInstantiationEvent( 

2762 connection_pools, ClientType.SYNC, self._credential_provider 

2763 ) 

2764 ) 

2765 

2766 def create_redis_node( 

2767 self, 

2768 host, 

2769 port, 

2770 **kwargs, 

2771 ): 

2772 # We are configuring the connection pool not to retry 

2773 # connections on lower level clients to avoid retrying 

2774 # connections to nodes that are not reachable 

2775 # and to avoid blocking the connection pool. 

2776 # The only error that will have some handling in the lower 

2777 # level clients is ConnectionError which will trigger disconnection 

2778 # of the socket. 

2779 # The retries will be handled on cluster client level 

2780 # where we will have proper handling of the cluster topology 

2781 node_retry_config = Retry( 

2782 backoff=NoBackoff(), retries=0, supported_errors=(ConnectionError,) 

2783 ) 

2784 

2785 if self.from_url: 

2786 # Create a redis node with a custom connection pool 

2787 kwargs.update({"host": host}) 

2788 kwargs.update({"port": port}) 

2789 kwargs.update({"cache": self._cache}) 

2790 kwargs.update({"metadata_resolver": self._metadata_resolver}) 

2791 kwargs.update({"retry": node_retry_config}) 

2792 r = Redis(connection_pool=self.connection_pool_class(**kwargs)) 

2793 else: 

2794 r = Redis( 

2795 host=host, 

2796 port=port, 

2797 cache=self._cache, 

2798 metadata_resolver=self._metadata_resolver, 

2799 retry=node_retry_config, 

2800 **kwargs, 

2801 ) 

2802 # Share the one cluster-wide HIMPORT registry with this node's pool. Injected 

2803 # here (rather than forwarded via connection_kwargs) so every node reuses the 

2804 # same object; the node has no connections yet, so this is safe. 

2805 if self.himport_registry is not None: 

2806 r.connection_pool.himport_registry = self.himport_registry 

2807 r.connection_pool.connection_kwargs["himport_registry"] = ( 

2808 self.himport_registry 

2809 ) 

2810 return r 

2811 

2812 def _get_or_create_cluster_node(self, host, port, role, tmp_nodes_cache): 

2813 node_name = get_node_name(host, port) 

2814 # check if we already have this node in the tmp_nodes_cache 

2815 target_node = tmp_nodes_cache.get(node_name) 

2816 if target_node is None: 

2817 # before creating a new cluster node, check if the cluster node already 

2818 # exists in the current nodes cache and has a valid connection so we can 

2819 # reuse it 

2820 redis_connection: Optional[Redis] = None 

2821 with self._lock: 

2822 previous_node = self.nodes_cache.get(node_name) 

2823 if previous_node: 

2824 redis_connection = previous_node.redis_connection 

2825 # don't update the old ClusterNode, so we don't update its role 

2826 # outside of the lock 

2827 target_node = ClusterNode(host, port, role, redis_connection) 

2828 # add this node to the nodes cache 

2829 tmp_nodes_cache[target_node.name] = target_node 

2830 

2831 return target_node 

2832 

2833 def _get_epoch(self) -> int: 

2834 """ 

2835 Get the current epoch value. This method exists primarily to allow 

2836 tests to mock the epoch fetch and control race condition timing. 

2837 """ 

2838 with self._lock: 

2839 return self._epoch 

2840 

2841 @contextmanager 

2842 def _initializing_on_this_thread(self): 

2843 """ 

2844 Mark the calling thread as the one currently running ``initialize``. 

2845 

2846 Must be entered while holding ``_initialization_lock`` - that is what 

2847 makes the calling thread the only writer of ``_initializing_thread_id``. 

2848 """ 

2849 self._initializing_thread_id = threading.get_ident() 

2850 try: 

2851 yield 

2852 finally: 

2853 self._initializing_thread_id = None 

2854 

2855 def initialize( 

2856 self, 

2857 additional_startup_nodes_info: Optional[List[Tuple[str, int]]] = None, 

2858 disconnect_startup_nodes_pools: bool = True, 

2859 last_failed_node_name: Optional[str] = None, 

2860 ): 

2861 """ 

2862 Initializes the nodes cache, slots cache and redis connections. 

2863 :startup_nodes: 

2864 Responsible for discovering other nodes in the cluster 

2865 :disconnect_startup_nodes_pools: 

2866 Whether to disconnect the connection pool of the startup nodes 

2867 after the initialization is complete. This is useful when the 

2868 startup nodes are not part of the cluster and we want to avoid 

2869 keeping the connection open. 

2870 :additional_startup_nodes_info: 

2871 Additional nodes to add temporarily to the startup nodes. 

2872 The additional nodes will be used just in the process of extraction of the slots 

2873 and nodes information from the cluster. 

2874 This is useful when we want to add new nodes to the cluster 

2875 and initialize the client 

2876 with them. 

2877 The format of the list is a list of tuples, where each tuple contains 

2878 the host and port of the node. 

2879 :last_failed_node_name: 

2880 Name of the node that just failed and should be tried only after 

2881 other startup and additional startup nodes during this refresh. 

2882 """ 

2883 if self._initializing_thread_id == threading.get_ident(): 

2884 # Re-entrant call on the thread that is already refreshing the 

2885 # topology: a push notification (SMIGRATED) arrived on the CLUSTER 

2886 # SLOTS response below and was handled inline on this thread. The 

2887 # outer call is mid-refresh and will publish its own result, so 

2888 # running a nested refresh here would reset() and swap the caches 

2889 # underneath it, only for the outer call to overwrite them again 

2890 # with its older snapshot. Skip instead; the outer refresh reads the 

2891 # authoritative slot map anyway, and anything it still misses is 

2892 # recovered through MOVED redirection. 

2893 # 

2894 # Reading the attribute without the lock is safe: while this thread 

2895 # holds _initialization_lock it is the only writer, so a match can 

2896 # only ever mean "this thread set it". A stale ident belonging to 

2897 # another thread simply fails the comparison and falls through to 

2898 # the normal blocking acquire below. 

2899 if is_debug_log_enabled(): 

2900 logger.debug( 

2901 "Topology refresh: skipping re-entrant initialize on thread " 

2902 f"{threading.get_ident()}" 

2903 ) 

2904 return 

2905 self.reset() 

2906 tmp_nodes_cache = {} 

2907 tmp_slots = {} 

2908 disagreements = [] 

2909 startup_nodes_reachable = False 

2910 fully_covered = False 

2911 kwargs = self.connection_kwargs 

2912 exception = None 

2913 epoch = self._get_epoch() 

2914 if additional_startup_nodes_info is None: 

2915 additional_startup_nodes_info = [] 

2916 

2917 with self._initialization_lock, self._initializing_on_this_thread(): 

2918 with self._lock: 

2919 if epoch != self._epoch: 

2920 # another thread has already re-initialized the nodes; don't 

2921 # bother running again 

2922 return 

2923 

2924 with self._lock: 

2925 startup_nodes = list(self.startup_nodes.values()) 

2926 deferred_failed_nodes = [] 

2927 if last_failed_node_name is not None: 

2928 for index, node in enumerate(startup_nodes): 

2929 if node.name == last_failed_node_name: 

2930 deferred_failed_nodes.append(startup_nodes.pop(index)) 

2931 break 

2932 if len(startup_nodes) > 1: 

2933 # Vary which startup node is queried first so clients do not 

2934 # all reinitialize through the same node. 

2935 random.shuffle(startup_nodes) 

2936 

2937 additional_startup_nodes = [ 

2938 ClusterNode(host, port) for host, port in additional_startup_nodes_info 

2939 ] 

2940 if last_failed_node_name is not None: 

2941 for index, node in enumerate(additional_startup_nodes): 

2942 if node.name == last_failed_node_name: 

2943 if not deferred_failed_nodes: 

2944 deferred_failed_nodes.append(node) 

2945 additional_startup_nodes.pop(index) 

2946 break 

2947 if is_debug_log_enabled(): 

2948 logger.debug( 

2949 f"Topology refresh: using additional nodes: {[node.name for node in additional_startup_nodes]}; " 

2950 f"and startup nodes: {[node.name for node in startup_nodes]}" 

2951 ) 

2952 

2953 for startup_node in chain( 

2954 startup_nodes, 

2955 additional_startup_nodes, 

2956 deferred_failed_nodes, 

2957 ): 

2958 try: 

2959 if startup_node.redis_connection: 

2960 r = startup_node.redis_connection 

2961 

2962 else: 

2963 # Create a new Redis connection 

2964 if is_debug_log_enabled(): 

2965 socket_timeout = kwargs.get("socket_timeout", "not set") 

2966 socket_connect_timeout = kwargs.get( 

2967 "socket_connect_timeout", "not set" 

2968 ) 

2969 maint_enabled = ( 

2970 self.maint_notifications_config.enabled 

2971 if self.maint_notifications_config 

2972 else False 

2973 ) 

2974 logger.debug( 

2975 "Topology refresh: Creating new Redis connection to " 

2976 f"{startup_node.host}:{startup_node.port}; " 

2977 f"with socket_timeout: {socket_timeout}, and " 

2978 f"socket_connect_timeout: {socket_connect_timeout}, " 

2979 "and maint_notifications enabled: " 

2980 f"{maint_enabled}" 

2981 ) 

2982 r = self.create_redis_node( 

2983 startup_node.host, 

2984 startup_node.port, 

2985 maint_notifications_config=self.maint_notifications_config, 

2986 **kwargs, 

2987 ) 

2988 if startup_node in self.startup_nodes.values(): 

2989 self.startup_nodes[startup_node.name].redis_connection = r 

2990 else: 

2991 startup_node.redis_connection = r 

2992 try: 

2993 if is_debug_log_enabled(): 

2994 logger.debug( 

2995 "Topology refresh: querying CLUSTER SLOTS on " 

2996 f"{startup_node.name}" 

2997 ) 

2998 # Make sure cluster mode is enabled on this node 

2999 cluster_slots = str_if_bytes(r.execute_command("CLUSTER SLOTS")) 

3000 if disconnect_startup_nodes_pools: 

3001 with r.connection_pool._lock: 

3002 # take care to clear connections before we move on 

3003 # mark all active connections for reconnect - they will be 

3004 # reconnected on next use, but will allow current in flight commands to complete first 

3005 r.connection_pool.update_active_connections_for_reconnect() 

3006 # Needed to clear READONLY state when it is no longer applicable 

3007 r.connection_pool.disconnect_free_connections() 

3008 except ResponseError: 

3009 raise RedisClusterException( 

3010 "Cluster mode is not enabled on this node" 

3011 ) 

3012 startup_nodes_reachable = True 

3013 except Exception as e: 

3014 # Try the next startup node. 

3015 # The exception is saved and raised only if we have no more nodes. 

3016 if is_debug_log_enabled(): 

3017 logger.debug( 

3018 "Topology refresh: CLUSTER SLOTS failed on " 

3019 f"{startup_node.name}: {type(e).__name__}: {e}" 

3020 ) 

3021 exception = e 

3022 continue 

3023 

3024 # CLUSTER SLOTS command results in the following output: 

3025 # [[slot_section[from_slot,to_slot,master,replica1,...,replicaN]]] 

3026 # where each node contains the following list: [IP, port, node_id] 

3027 # Therefore, cluster_slots[0][2][0] will be the IP address of the 

3028 # primary node of the first slot section. 

3029 # If there's only one server in the cluster, its ``host`` is '' 

3030 # Fix it to the host in startup_nodes 

3031 if ( 

3032 len(cluster_slots) == 1 

3033 and len(cluster_slots[0][2][0]) == 0 

3034 and len(self.startup_nodes) == 1 

3035 ): 

3036 cluster_slots[0][2][0] = startup_node.host 

3037 

3038 for slot in cluster_slots: 

3039 primary_node = slot[2] 

3040 host = str_if_bytes(primary_node[0]) 

3041 if host == "": 

3042 host = startup_node.host 

3043 port = int(primary_node[1]) 

3044 host, port = self.remap_host_port(host, port) 

3045 

3046 nodes_for_slot = [] 

3047 

3048 target_node = self._get_or_create_cluster_node( 

3049 host, port, PRIMARY, tmp_nodes_cache 

3050 ) 

3051 nodes_for_slot.append(target_node) 

3052 

3053 replica_nodes = slot[3:] 

3054 for replica_node in replica_nodes: 

3055 host = str_if_bytes(replica_node[0]) 

3056 port = int(replica_node[1]) 

3057 host, port = self.remap_host_port(host, port) 

3058 target_replica_node = self._get_or_create_cluster_node( 

3059 host, port, REPLICA, tmp_nodes_cache 

3060 ) 

3061 nodes_for_slot.append(target_replica_node) 

3062 

3063 for i in range(int(slot[0]), int(slot[1]) + 1): 

3064 if i not in tmp_slots: 

3065 tmp_slots[i] = nodes_for_slot 

3066 else: 

3067 # Validate that 2 nodes want to use the same slot cache 

3068 # setup 

3069 tmp_slot = tmp_slots[i][0] 

3070 if tmp_slot.name != target_node.name: 

3071 disagreements.append( 

3072 f"{tmp_slot.name} vs {target_node.name} on slot: {i}" 

3073 ) 

3074 

3075 if len(disagreements) > 5: 

3076 raise RedisClusterException( 

3077 f"startup_nodes could not agree on a valid " 

3078 f"slots cache: {', '.join(disagreements)}" 

3079 ) 

3080 

3081 fully_covered = self.check_slots_coverage(tmp_slots) 

3082 if is_debug_log_enabled(): 

3083 logger.debug( 

3084 f"Topology refresh: CLUSTER SLOTS from {startup_node.name} " 

3085 f"reported nodes {sorted(tmp_nodes_cache)}; " 

3086 f"slots fully covered: {fully_covered}" 

3087 ) 

3088 if fully_covered: 

3089 # Don't need to continue to the next startup node if all 

3090 # slots are covered 

3091 break 

3092 

3093 if not startup_nodes_reachable: 

3094 # The unreachable subtype is reserved for connectivity failures: 

3095 # MultiDB registers it as retryable, so a deterministic 

3096 # server/configuration error (e.g. cluster mode disabled or 

3097 # invalid credentials - AuthenticationError and 

3098 # AuthorizationError subclass ConnectionError but cannot be 

3099 # repaired by a failover) must keep surfacing as a plain 

3100 # RedisClusterException. 

3101 if isinstance( 

3102 exception, (ConnectionError, TimeoutError, OSError) 

3103 ) and not isinstance( 

3104 exception, (AuthenticationError, AuthorizationError) 

3105 ): 

3106 raise RedisClusterUnreachableError( 

3107 f"Redis Cluster cannot be connected. Please provide at least " 

3108 f"one reachable node: {str(exception)}" 

3109 ) from exception 

3110 raise RedisClusterException( 

3111 f"Redis Cluster cannot be connected. Please provide at least " 

3112 f"one reachable node: {str(exception)}" 

3113 ) from exception 

3114 

3115 # Create Redis connections to all nodes 

3116 self.create_redis_connections(list(tmp_nodes_cache.values())) 

3117 

3118 # Check if the slots are not fully covered 

3119 if not fully_covered and self._require_full_coverage: 

3120 # Despite the requirement that the slots be covered, there 

3121 # isn't a full coverage 

3122 raise RedisClusterException( 

3123 f"All slots are not covered after query all startup_nodes. " 

3124 f"{len(tmp_slots)} of {REDIS_CLUSTER_HASH_SLOTS} " 

3125 f"covered..." 

3126 ) 

3127 

3128 # Set the tmp variables to the real variables 

3129 with self._lock: 

3130 self.nodes_cache = tmp_nodes_cache 

3131 self.slots_cache = tmp_slots 

3132 # Set the default node 

3133 self.default_node = self.get_nodes_by_server_type(PRIMARY)[0] 

3134 if self._dynamic_startup_nodes: 

3135 # Populate the startup nodes with all discovered nodes 

3136 self.startup_nodes = tmp_nodes_cache 

3137 # Increment the epoch to signal that initialization has completed 

3138 self._epoch += 1 

3139 # Dispatch so listeners (e.g. ClusterPubSub) can reconcile per-node 

3140 # state after slot ownership may have changed. A listener must not 

3141 # break slots-cache refresh; log and continue so a single buggy 

3142 # listener cannot starve the rest. 

3143 try: 

3144 self._event_dispatcher.dispatch(AfterSlotsCacheRefreshEvent()) 

3145 except Exception as e: 

3146 logger.exception( 

3147 "listener raised during slots-cache refresh: %s: %s", 

3148 type(e).__name__, 

3149 e, 

3150 ) 

3151 

3152 def close(self) -> None: 

3153 with self._lock: 

3154 self.default_node = None 

3155 nodes = tuple(self.nodes_cache.values()) 

3156 for node in nodes: 

3157 if node.redis_connection: 

3158 node.redis_connection.close() 

3159 

3160 def reset(self): 

3161 try: 

3162 self.read_load_balancer.reset() 

3163 except TypeError: 

3164 # The read_load_balancer is None, do nothing 

3165 pass 

3166 

3167 def remap_host_port(self, host: str, port: int) -> Tuple[str, int]: 

3168 """ 

3169 Remap the host and port returned from the cluster to a different 

3170 internal value. Useful if the client is not connecting directly 

3171 to the cluster. 

3172 """ 

3173 if self.address_remap: 

3174 return self.address_remap((host, port)) 

3175 return host, port 

3176 

3177 def find_connection_owner(self, connection: Connection) -> Optional[ClusterNode]: 

3178 node_name = get_node_name(connection.host, connection.port) 

3179 with self._lock: 

3180 for node in tuple(self.nodes_cache.values()): 

3181 if node.redis_connection: 

3182 conn_args = node.redis_connection.connection_pool.connection_kwargs 

3183 if node_name == get_node_name( 

3184 conn_args.get("host"), conn_args.get("port") 

3185 ): 

3186 return node 

3187 return None 

3188 

3189 

3190def _unregister_slots_cache_listener( 

3191 dispatcher_ref: "weakref.ref[EventDispatcher]", 

3192 listener: EventListenerInterface, 

3193 event_type: Type[object], 

3194) -> None: 

3195 # Module-level finalizer callback. Kept free of strong references to the 

3196 # owning ClusterPubSub so attaching it via weakref.finalize does not 

3197 # extend the pubsub's lifetime. 

3198 dispatcher = dispatcher_ref() 

3199 if dispatcher is not None: 

3200 dispatcher.unregister_listeners({event_type: [listener]}) 

3201 

3202 

3203class ClusterPubSubSlotsCacheListener(EventListenerInterface): 

3204 """ 

3205 Listener that forwards AfterSlotsCacheRefreshEvent to a ClusterPubSub. 

3206 

3207 Holds a weak reference to the pubsub so it does not keep the instance 

3208 alive. Deterministic cleanup of the dispatcher's strong reference to this 

3209 listener is performed by a ``weakref.finalize`` attached to the owning 

3210 ClusterPubSub in ``ClusterPubSub.__init__``. 

3211 """ 

3212 

3213 def __init__(self, pubsub: "ClusterPubSub") -> None: 

3214 self._pubsub_ref: "weakref.ref[ClusterPubSub]" = weakref.ref(pubsub) 

3215 

3216 def listen(self, event: object) -> None: 

3217 pubsub = self._pubsub_ref() 

3218 if pubsub is None: 

3219 # Race window between pubsub GC and the finalizer running; safe 

3220 # no-op, finalizer will remove this listener shortly. 

3221 return 

3222 try: 

3223 pubsub.on_slots_changed() 

3224 except Exception as e: 

3225 # Listeners must not break slots-cache refresh; log and continue so 

3226 # a single buggy pubsub cannot starve the rest. 

3227 logger.exception( 

3228 "pubsub %r raised during slots-cache change: %s: %s", 

3229 pubsub, 

3230 type(e).__name__, 

3231 e, 

3232 ) 

3233 

3234 

3235# How long a per-node sharded-pubsub connection is skipped by the round robin 

3236# after a failed poll. PubSub._execute reconnects and retries through the 

3237# connection's own Retry, so one poll on an unreachable node can cost its whole 

3238# retry budget rather than the timeout the caller asked for; a cool-off keeps 

3239# the single reader from spending every pass on that node while its healthy 

3240# siblings hold undelivered messages. 

3241SHARD_POLL_COOL_OFF_SECONDS = 1.0 

3242 

3243# How long an unbounded sharded-pubsub poll waits for a not-yet-subscribed 

3244# per-node pubsub before re-checking that the pubsub is still in 

3245# node_pubsub_mapping. A retired one is never resubscribed and its 

3246# subscribed_event is never set again, so an uninterrupted wait would park the 

3247# single reader for good and stop delivery from every healthy sibling too. 

3248# Only a poll that would otherwise block indefinitely ticks at all. 

3249SHARD_SUBSCRIBE_WAIT_TICK_SECONDS = 1.0 

3250 

3251# How often a failed sharded-pubsub poll may trigger a slots-cache refresh. 

3252# Reconciliation is otherwise purely event-driven, and a node that has left the 

3253# deployment answers ECONNREFUSED rather than MOVED - so without this the reader 

3254# would cool off against the departed node forever and the shard channels pinned 

3255# to it would never move to their new owner. Throttled because the refresh costs 

3256# a CLUSTER SLOTS round trip and a failing node fails every poll. 

3257SHARD_TOPOLOGY_REPAIR_INTERVAL_SECONDS = 5.0 

3258 

3259 

3260class ClusterPubSub(PubSub): 

3261 """ 

3262 Wrapper for PubSub class. 

3263 

3264 IMPORTANT: before using ClusterPubSub, read about the known limitations 

3265 with pubsub in Cluster mode and learn how to workaround them: 

3266 https://redis.readthedocs.io/en/stable/clustering.html#known-pubsub-limitations 

3267 """ 

3268 

3269 def __init__( 

3270 self, 

3271 redis_cluster, 

3272 node=None, 

3273 host=None, 

3274 port=None, 

3275 push_handler_func=None, 

3276 event_dispatcher: Optional["EventDispatcher"] = None, 

3277 **kwargs, 

3278 ): 

3279 """ 

3280 When a pubsub instance is created without specifying a node, a single 

3281 node will be transparently chosen for the pubsub connection on the 

3282 first command execution. The node will be determined by: 

3283 1. Hashing the channel name in the request to find its keyslot 

3284 2. Selecting a node that handles the keyslot: If read_from_replicas is 

3285 set to true or load_balancing_strategy is set, a replica can be selected. 

3286 

3287 :type redis_cluster: RedisCluster 

3288 :type node: ClusterNode 

3289 :type host: str 

3290 :type port: int 

3291 """ 

3292 self.node = None 

3293 self.set_pubsub_node(redis_cluster, node, host, port) 

3294 connection_pool = ( 

3295 None 

3296 if self.node is None 

3297 else redis_cluster.get_redis_connection(self.node).connection_pool 

3298 ) 

3299 self.cluster = redis_cluster 

3300 self.node_pubsub_mapping = {} 

3301 # Reverse index: shard channel (normalized) -> owning node.name. Used to 

3302 # route sunsubscribe calls and reconcile subscriptions after slot 

3303 # migration / failover. 

3304 self._shard_channel_to_node: dict = {} 

3305 # Per-node poll cool-off deadlines (monotonic). Weak-keyed so a 

3306 # per-node pubsub dropped from node_pubsub_mapping takes its entry with 

3307 # it instead of leaking one per migration. 

3308 self._poll_cool_off: "weakref.WeakKeyDictionary[PubSub, float]" = ( 

3309 weakref.WeakKeyDictionary() 

3310 ) 

3311 # Node names whose last poll failed to connect. Read by 

3312 # _migrate_shard_channel to skip a wire SUNSUBSCRIBE that cannot 

3313 # succeed, and cleared as soon as a poll on that node works again. 

3314 self._unreachable_nodes: Set[str] = set() 

3315 # Monotonic deadline before which a failed poll must not trigger 

3316 # another slots-cache refresh. 0.0 means "never refreshed". 

3317 self._next_topology_repair: float = 0.0 

3318 # Dedicated lock for shard-subscription bookkeeping. Distinct from 

3319 # PubSub.self._lock (which serializes wire I/O on the cluster-level 

3320 # connection used by aclose / send_command / regular subscribe) so 

3321 # that reconciliation cannot starve those unrelated paths during 

3322 # long per-channel migrations. 

3323 self._shard_state_lock: threading.RLock = threading.RLock() 

3324 # Worker executor for off-loading slot-migration reconciliation from 

3325 # the dispatch call site (mirrors async's asyncio.create_task model so 

3326 # the thread that triggered MovedError / topology refresh is not 

3327 # blocked on per-channel sunsubscribe / ssubscribe network I/O). 

3328 # 

3329 # Installed by reset(), which PubSub.__init__ calls below, and replaced 

3330 # by every later reset() - so it is never None once construction has 

3331 # finished and the scheduling sites need no lock to create it. That 

3332 # matters: on_slots_changed runs inline on whichever thread refreshed 

3333 # the topology, which can be a thread that handled an SMIGRATED push and 

3334 # still holds OSSMaintNotificationsHandler._lock while a third thread 

3335 # waits for that handler lock holding a per-node pubsub I/O lock the 

3336 # reconciliation worker needs - so blocking there on _shard_state_lock 

3337 # closes a three-thread deadlock cycle. 

3338 # 

3339 # Constructing an executor starts no thread; ThreadPoolExecutor spawns 

3340 # its worker on the first submit(), so pubsubs that never see a slot 

3341 # migration never pay for one. Declared before super().__init__() 

3342 # because reset() reads it. 

3343 self._reconcile_executor: Optional[ThreadPoolExecutor] = None 

3344 self._pubsubs_generator = self._pubsubs_generator() 

3345 if event_dispatcher is None: 

3346 self._event_dispatcher = EventDispatcher() 

3347 else: 

3348 self._event_dispatcher = event_dispatcher 

3349 super().__init__( 

3350 connection_pool=connection_pool, 

3351 encoder=redis_cluster.encoder, 

3352 push_handler_func=push_handler_func, 

3353 event_dispatcher=self._event_dispatcher, 

3354 **kwargs, 

3355 ) 

3356 # Subscribe to slots-cache change notifications so shard subscriptions 

3357 # can be reconciled automatically after topology refreshes. 

3358 nm_dispatcher = redis_cluster.nodes_manager._event_dispatcher 

3359 self._slots_cache_listener = ClusterPubSubSlotsCacheListener(self) 

3360 nm_dispatcher.register_listeners( 

3361 {AfterSlotsCacheRefreshEvent: [self._slots_cache_listener]} 

3362 ) 

3363 # Deterministic GC-time cleanup so short-lived pubsubs do not leak 

3364 # listeners in the dispatcher when no slots-refresh event ever fires. 

3365 weakref.finalize( 

3366 self, 

3367 _unregister_slots_cache_listener, 

3368 weakref.ref(nm_dispatcher), 

3369 self._slots_cache_listener, 

3370 AfterSlotsCacheRefreshEvent, 

3371 ) 

3372 

3373 def set_pubsub_node(self, cluster, node=None, host=None, port=None): 

3374 """ 

3375 The pubsub node will be set according to the passed node, host and port 

3376 When none of the node, host, or port are specified - the node is set 

3377 to None and will be determined by the keyslot of the channel in the 

3378 first command to be executed. 

3379 RedisClusterException will be thrown if the passed node does not exist 

3380 in the cluster. 

3381 If host is passed without port, or vice versa, a DataError will be 

3382 thrown. 

3383 :type cluster: RedisCluster 

3384 :type node: ClusterNode 

3385 :type host: str 

3386 :type port: int 

3387 """ 

3388 if node is not None: 

3389 # node is passed by the user 

3390 self._raise_on_invalid_node(cluster, node, node.host, node.port) 

3391 pubsub_node = node 

3392 elif host is not None and port is not None: 

3393 # host and port passed by the user 

3394 node = cluster.get_node(host=host, port=port) 

3395 self._raise_on_invalid_node(cluster, node, host, port) 

3396 pubsub_node = node 

3397 elif any([host, port]) is True: 

3398 # only 'host' or 'port' passed 

3399 raise DataError("Passing a host requires passing a port, and vice versa") 

3400 else: 

3401 # nothing passed by the user. set node to None 

3402 pubsub_node = None 

3403 

3404 self.node = pubsub_node 

3405 

3406 def get_pubsub_node(self): 

3407 """ 

3408 Get the node that is being used as the pubsub connection 

3409 """ 

3410 return self.node 

3411 

3412 def _raise_on_invalid_node(self, redis_cluster, node, host, port): 

3413 """ 

3414 Raise a RedisClusterException if the node is None or doesn't exist in 

3415 the cluster. 

3416 """ 

3417 if node is None or redis_cluster.get_node(node_name=node.name) is None: 

3418 raise RedisClusterException( 

3419 f"Node {host}:{port} doesn't exist in the cluster" 

3420 ) 

3421 

3422 def execute_command(self, *args): 

3423 """ 

3424 Execute a subscribe/unsubscribe command. 

3425 

3426 Taken code from redis-py and tweak to make it work within a cluster. 

3427 """ 

3428 # NOTE: don't parse the response in this function -- it could pull a 

3429 # legitimate message off the stack if the connection is already 

3430 # subscribed to one or more channels 

3431 

3432 # For shard commands, route to appropriate node 

3433 command = args[0].upper() if args else "" 

3434 if command in ("SSUBSCRIBE", "SUNSUBSCRIBE", "SPUBLISH"): 

3435 if len(args) > 1: 

3436 # ssubscribe / sunsubscribe own both the per-node I/O lock and 

3437 # the shard_channels / _shard_channel_to_node bookkeeping, so 

3438 # delegate to them instead of dispatching raw. A raw dispatch 

3439 # writes the socket unguarded against a concurrent poll and 

3440 # records nothing, leaving the channel invisible to the reader 

3441 # loop and to on_connect's replay. 

3442 if command == "SSUBSCRIBE": 

3443 return self.ssubscribe(*args[1:]) 

3444 if command == "SUNSUBSCRIBE": 

3445 return self.sunsubscribe(*args[1:]) 

3446 channel = args[1] 

3447 node = self.cluster.get_node_from_key(channel) 

3448 if node: 

3449 pubsub = self._get_node_pubsub(node) 

3450 with self._pubsub_io_lock(pubsub): 

3451 return pubsub.execute_command(*args) 

3452 

3453 # For other commands, use the set node or lazily discover one 

3454 if self.connection is None: 

3455 if self.connection_pool is None: 

3456 if len(args) > 1: 

3457 # Hash the first channel and get one of the nodes holding 

3458 # this slot 

3459 channel = args[1] 

3460 slot = self.cluster.keyslot(channel) 

3461 node = self.cluster.nodes_manager.get_node_from_slot( 

3462 slot, 

3463 self.cluster.read_from_replicas, 

3464 self.cluster.load_balancing_strategy, 

3465 ) 

3466 else: 

3467 # Get a random node 

3468 node = self.cluster.get_random_node() 

3469 self.node = node 

3470 redis_connection = self.cluster.get_redis_connection(node) 

3471 self.connection_pool = redis_connection.connection_pool 

3472 self.connection = self.connection_pool.get_connection() 

3473 # register a callback that re-subscribes to any channels we 

3474 # were listening to when we were disconnected 

3475 self.connection.register_connect_callback(self.on_connect) 

3476 if self.push_handler_func is not None: 

3477 self.connection._parser.set_pubsub_push_handler(self.push_handler_func) 

3478 self._event_dispatcher.dispatch( 

3479 AfterPubSubConnectionInstantiationEvent( 

3480 self.connection, self.connection_pool, ClientType.SYNC, self._lock 

3481 ) 

3482 ) 

3483 connection = self.connection 

3484 self._execute(connection, connection.send_command, *args) 

3485 

3486 def _resubscribe_shard_channels(self) -> None: 

3487 # A single node can own multiple slot ranges, so a batched 

3488 # ``SSUBSCRIBE`` covering every tracked channel would be rejected by 

3489 # Redis with a ``CROSSSLOT`` error. Group by hash slot and emit one 

3490 # ``SSUBSCRIBE`` per slot. 

3491 by_slot: defaultdict[int, dict] = defaultdict(dict) 

3492 for k, v in self.shard_channels.items(): 

3493 by_slot[key_slot(self.encoder.encode(k))][k] = v 

3494 for subscriptions in by_slot.values(): 

3495 self._resubscribe(subscriptions, self.ssubscribe) 

3496 

3497 def _get_node_pubsub(self, node): 

3498 try: 

3499 return self.node_pubsub_mapping[node.name] 

3500 except KeyError: 

3501 redis_connection = self.cluster.get_redis_connection(node) 

3502 pubsub = redis_connection.pubsub( 

3503 push_handler_func=self.push_handler_func, 

3504 ) 

3505 # Replay shard subscriptions on reconnect with slot-aware grouping 

3506 # so that channels spanning multiple slots owned by this node do 

3507 # not trigger a CROSSSLOT error. 

3508 pubsub._resubscribe_shard_channels = MethodType( 

3509 ClusterPubSub._resubscribe_shard_channels, pubsub 

3510 ) 

3511 self._pubsub_io_lock(pubsub) 

3512 self.node_pubsub_mapping[node.name] = pubsub 

3513 return pubsub 

3514 

3515 def _find_node_name_for_pubsub(self, pubsub): 

3516 # Snapshot the items: every caller but one runs without 

3517 # _shard_state_lock, so iterating the mapping directly would raise 

3518 # "dictionary changed size during iteration" whenever a concurrent 

3519 # migration adds or retires a per-node pubsub - which is exactly when 

3520 # these lookups happen. list() of a dict view is atomic under the GIL. 

3521 for node_name, node_pubsub in list(self.node_pubsub_mapping.items()): 

3522 if node_pubsub is pubsub: 

3523 return node_name 

3524 return None 

3525 

3526 @staticmethod 

3527 def _pubsub_io_lock(pubsub) -> threading.RLock: 

3528 """Return the per-node pubsub's wire I/O lock, creating it on first use. 

3529 

3530 A per-node ``PubSub`` is read by whichever thread polls 

3531 ``get_sharded_message`` and written by the reconciliation worker 

3532 (``_migrate_shard_channel``) and by any caller of ``ssubscribe`` / 

3533 ``sunsubscribe``. ``PubSub`` guards writes with its own ``_lock`` 

3534 (``PubSub.execute_command``) but reads take no lock at all, so without 

3535 this the reader can be inside ``read_response`` while another thread's 

3536 ``_execute`` disconnects and reconnects the same socket underneath it - 

3537 which loses the reply to the handshake and surfaces as a read timeout 

3538 followed by ``EBADF``. 

3539 

3540 Kept on the pubsub rather than in a dict keyed by node name so it 

3541 travels with the object through ``node_pubsub_mapping`` and cannot go 

3542 stale when a per-node pubsub is dropped and recreated. 

3543 """ 

3544 lock = getattr(pubsub, "_shard_io_lock", None) 

3545 if lock is None: 

3546 lock = threading.RLock() 

3547 pubsub._shard_io_lock = lock 

3548 return lock 

3549 

3550 @staticmethod 

3551 def _detach_shard_channel(pubsub, channel) -> None: 

3552 """Forget a shard channel on a per-node pubsub without a wire round trip. 

3553 

3554 ``PubSub.sunsubscribe`` only records the intent in 

3555 ``pending_unsubscribe_shard_channels``; the channel leaves 

3556 ``shard_channels`` when the server confirmation is read. So if the 

3557 ``SUNSUBSCRIBE`` never reaches the server, ``on_connect`` clears the 

3558 pending set and replays ``SSUBSCRIBE`` for the channel - on the node it 

3559 is being migrated away from, on every reconnect. Once the caller has 

3560 decided the channel belongs to a different node, the local intent is 

3561 the only truth left, so drop it here. 

3562 

3563 Deliberately without the per-node I/O lock, unlike every other writer 

3564 on a per-node pubsub. ``_forget_shard_channel_on_old_node`` calls this 

3565 for a node the reader has just failed to reach, and a bounded poll holds 

3566 that lock across ``PubSub._execute``'s reconnect and its whole retry 

3567 budget - far longer than the timeout the poll was given. Waiting for it 

3568 here, while this pass holds ``_shard_state_lock``, is the migration 

3569 slow enough to look like a permanent delivery stall that the 

3570 ``_unreachable_nodes`` fast path exists to avoid. 

3571 

3572 Nothing needs the lock: each mutation below is individually atomic, and 

3573 every interleaving with ``handle_message``'s unsubscribe bookkeeping (the 

3574 only concurrent writer of the same state, and one that runs under that 

3575 lock) converges on the same end state - the channel gone from both the 

3576 subscription dict and the pending set. That bookkeeping discards rather 

3577 than removes precisely so this detach cannot make it raise ``KeyError`` 

3578 into a poll no caller catches. 

3579 """ 

3580 pubsub.shard_channels.pop(channel, None) 

3581 pubsub.pending_unsubscribe_shard_channels.discard(channel) 

3582 if not pubsub.channels and not pubsub.patterns and not pubsub.shard_channels: 

3583 pubsub.subscribed_event.clear() 

3584 

3585 def _drop_node_pubsub(self, name: str, pubsub) -> None: 

3586 """Retire a per-node pubsub and drop it from ``node_pubsub_mapping``. 

3587 

3588 Callers hold ``_shard_state_lock``, the lock that every mutation of 

3589 that mapping observes. ``reset()`` runs under the per-node I/O lock so 

3590 the socket is not torn down beneath a concurrent bounded poll parked in 

3591 ``parse_response``, and its errors are swallowed: retiring one node's 

3592 pubsub must not abort the caller's pass, and this also runs from the 

3593 ``__del__`` fallback path through ``reset()``. 

3594 

3595 Every caller must leave nothing subscribed on ``pubsub`` (or have lost 

3596 the node itself). An empty per-node pubsub left in the mapping is what 

3597 ``_poll_node_pubsub`` stalls on: its ``subscribed_event`` is cleared, so 

3598 the prelude waits out the whole timeout of every pass - indefinitely when 

3599 the caller passed ``timeout=None``, since the ``reset()`` below only 

3600 clears that event again and no message can arrive on a subscription that 

3601 is gone. 

3602 

3603 Popping it from the mapping is also what makes the retirement 

3604 *observable*: that prelude gives up on an unbounded wait once the pubsub 

3605 it was handed is no longer mapped, which is the only thing standing 

3606 between a reader that held the object across this call and a permanent 

3607 park. The rebind below keeps it from being handed out to begin with. 

3608 """ 

3609 try: 

3610 with self._pubsub_io_lock(pubsub): 

3611 pubsub.reset() 

3612 except Exception: 

3613 pass 

3614 self.node_pubsub_mapping.pop(name, None) 

3615 self._unreachable_nodes.discard(name) 

3616 # Same snapshot reason ``reset()`` recreates this: ``_pubsubs_generator`` 

3617 # captures node_pubsub_mapping.values() into a local list inside 

3618 # ``yield from``, which the pop above does not reach - so a generator 

3619 # suspended mid-yield-from would still hand the object we just retired 

3620 # to the next poll. ``type(self)`` bypasses the instance-level 

3621 # self-shadow established at __init__. Costs nothing: constructing a 

3622 # generator runs no frame, so the per-node collection loop in 

3623 # reinitialize_shard_subscriptions can rebind once per dropped node. 

3624 self._pubsubs_generator = type(self)._pubsubs_generator(self) 

3625 

3626 def _sharded_message_generator(self, timeout=0.0): 

3627 first_error: Optional[BaseException] = None 

3628 polled = 0 

3629 failed = 0 

3630 next_ready: Optional[float] = None 

3631 for _ in range(len(self.node_pubsub_mapping)): 

3632 pubsub = next(self._pubsubs_generator) 

3633 if pubsub is None: 

3634 # node_pubsub_mapping was emptied between the len() above and 

3635 # here; nothing left to poll in this pass. 

3636 break 

3637 if self._poll_cool_off: 

3638 deadline = self._poll_cool_off.get(pubsub, 0.0) 

3639 if time.monotonic() < deadline: 

3640 # In cool-off after a failed poll: skip it so the reader 

3641 # spends this pass on the nodes that can still deliver. 

3642 if next_ready is None or deadline < next_ready: 

3643 next_ready = deadline 

3644 continue 

3645 polled += 1 

3646 try: 

3647 message = self._poll_node_pubsub(pubsub, timeout) 

3648 except MovedError as e: 

3649 # Handled, not failed: _handle_moved_on_read re-routes the 

3650 # offending channels and schedules reconciliation, so the next 

3651 # pass recovers. Re-raising a MovedError out of a pubsub read 

3652 # would only hand the caller an error it cannot act on. Still 

3653 # cool off: if the slots cache cannot be corrected the repair 

3654 # would otherwise re-run on every poll. 

3655 self._poll_cool_off[pubsub] = ( 

3656 time.monotonic() + SHARD_POLL_COOL_OFF_SECONDS 

3657 ) 

3658 self._handle_moved_on_read(pubsub, e) 

3659 continue 

3660 except (ConnectionError, TimeoutError, OSError) as e: 

3661 # One unhealthy node must not starve its healthy siblings. A 

3662 # single reader serves every per-node pubsub, so aborting the 

3663 # pass here stops delivery cluster-wide for as long as this one 

3664 # node stays unreachable - even though the slots it no longer 

3665 # serves are the only ones affected. Keep polling the rest and 

3666 # surface an error only if nothing in the pass worked, the same 

3667 # made-progress rule reinitialize_shard_subscriptions applies. 

3668 failed += 1 

3669 if first_error is None: 

3670 first_error = e 

3671 # Cool off before polling this one again. PubSub._execute 

3672 # reconnects and then retries through the connection's own 

3673 # Retry, so a single "bounded" poll on an unreachable node can 

3674 # cost its whole retry budget - far longer than the timeout the 

3675 # caller asked for. Without a cool-off the reader goes straight 

3676 # back to that node on the next pass and pays it again, which 

3677 # is what turns one sick node into a cluster-wide delivery 

3678 # stall. 

3679 self._poll_cool_off[pubsub] = ( 

3680 time.monotonic() + SHARD_POLL_COOL_OFF_SECONDS 

3681 ) 

3682 # Deliberately outside _shard_state_lock: this is an advisory 

3683 # hint for _migrate_shard_channel's fast path, mutated with 

3684 # single atomic set operations, and both misread directions are 

3685 # handled there and self-heal - a stale entry only skips a 

3686 # SUNSUBSCRIBE to a dead node, a missing one only pays a 

3687 # reconnect before the same local forget. 

3688 node_name = self._find_node_name_for_pubsub(pubsub) 

3689 if node_name is not None: 

3690 self._unreachable_nodes.add(node_name) 

3691 if is_debug_log_enabled(): 

3692 logger.debug( 

3693 "sharded pubsub poll failed on %s: %s: %s", 

3694 node_name, 

3695 type(e).__name__, 

3696 e, 

3697 ) 

3698 # A node that has left the deployment never answers MOVED, so 

3699 # this branch is the only signal that its shard channels may 

3700 # need a new owner. Ask for a slots-cache refresh; its dispatch 

3701 # reaches on_slots_changed and reconciles. 

3702 self._schedule_topology_repair() 

3703 continue 

3704 # Emptiness check first: this is the per-message hot path, and the 

3705 # weakref lookup a WeakKeyDictionary pop needs is pure overhead 

3706 # while no node is in cool-off, which is the normal case. 

3707 if self._poll_cool_off: 

3708 self._poll_cool_off.pop(pubsub, None) 

3709 if self._unreachable_nodes: 

3710 node_name = self._find_node_name_for_pubsub(pubsub) 

3711 if node_name is not None: 

3712 self._unreachable_nodes.discard(node_name) 

3713 if message is not None: 

3714 return pubsub, message 

3715 if first_error is not None and failed == polled: 

3716 raise first_error 

3717 if polled == 0 and next_ready is not None: 

3718 self._wait_out_cool_off(next_ready, timeout) 

3719 return None, None 

3720 

3721 @staticmethod 

3722 def _wait_out_cool_off(next_ready: float, timeout) -> None: 

3723 """Wait out a pass in which every node was skipped for cool-off. 

3724 

3725 Such a pass does no wire read at all, so returning straight away 

3726 ignores the timeout the caller asked to block for - and a reader loop 

3727 (``PubSubWorkerThread.run`` with ``sharded_pubsub=True``, or a user 

3728 loop on ``get_sharded_message``) polls back immediately, spinning 

3729 until the cool-off expires instead of blocking. Sleep instead: until 

3730 the earliest cool-off is over, never longer than the caller's timeout, 

3731 and not at all for a non-blocking poll. 

3732 """ 

3733 if timeout is not None and timeout <= 0: 

3734 return 

3735 delay = next_ready - time.monotonic() 

3736 if delay <= 0: 

3737 return 

3738 if timeout is not None: 

3739 delay = min(delay, timeout) 

3740 time.sleep(delay) 

3741 

3742 def _poll_io_lock(self, pubsub, timeout): 

3743 """Guard a per-node poll against concurrent writers on the same socket. 

3744 

3745 ``timeout=None`` makes ``_poll_node_pubsub``'s read block indefinitely, 

3746 so holding the lock across it would block reconciliation for as long as 

3747 no message arrives. Such a caller drives the pubsub itself and gets the 

3748 pre-existing unguarded behavior; every bounded poll - which is what 

3749 ``PubSubWorkerThread`` and ``ClusterPubSub``'s own callers use - is 

3750 serialized. 

3751 """ 

3752 if timeout is None: 

3753 return nullcontext() 

3754 return self._pubsub_io_lock(pubsub) 

3755 

3756 def _poll_node_pubsub(self, pubsub, timeout): 

3757 """Read one message from a per-node pubsub, dispatching outside the lock. 

3758 

3759 Splits ``PubSub.get_message`` so the per-node I/O lock covers the wire 

3760 read only. ``handle_message`` invokes a subscribed channel's user 

3761 handler inline, and a handler is free to call ``ssubscribe`` / 

3762 ``sunsubscribe`` on this ``ClusterPubSub`` - which takes 

3763 ``_shard_state_lock`` and then the same I/O lock. Holding the I/O lock 

3764 across the handler therefore deadlocks against the reconciliation 

3765 worker, which holds ``_shard_state_lock`` and waits for that I/O lock: 

3766 an ABBA cycle between two threads that the ``RLock``'s self-reentrancy 

3767 cannot break. The async counterpart's non-reentrant ``asyncio.Lock`` 

3768 hangs on the re-acquire alone, before any reconciliation is involved. 

3769 

3770 The two halves of ``handle_message`` are mutually exclusive: 

3771 ``UNSUBSCRIBE_MESSAGE_TYPES`` does subscription bookkeeping and never 

3772 reaches a handler, ``PUBLISH_MESSAGE_TYPES`` only dispatches. So 

3773 bookkeeping stays inside the lock - it mutates the very 

3774 ``shard_channels`` / ``pending_unsubscribe_shard_channels`` that 

3775 ``ssubscribe`` / ``sunsubscribe`` mutate under this lock - and only the 

3776 dispatch moves out. The cost is a narrow race: a reconciliation pass 

3777 that detaches the channel between the read and the dispatch makes the 

3778 handler lookup miss, so the message is returned to the caller instead 

3779 of dispatched. That is the same in-flight-during-unsubscribe race 

3780 ``PubSub`` itself has, and closing it would mean duplicating 

3781 ``handle_message``'s dispatch here. 

3782 """ 

3783 # ``PubSub.get_message``'s prelude, which bypassing it would drop: a 

3784 # per-node pubsub enters node_pubsub_mapping before its first 

3785 # SSUBSCRIBE (_get_node_pubsub) and is left connectionless by reset() 

3786 # (the GC in reinitialize_shard_subscriptions), while parse_response 

3787 # raises RuntimeError on a None connection - which neither poll site 

3788 # catches. Deliberately kept outside the I/O lock, unlike the 

3789 # get_message call it replaces: waiting here is not wire I/O, and the 

3790 # ssubscribe that sets this event needs the I/O lock itself, so waiting 

3791 # under it stalls the very subscribe being waited for. 

3792 if not pubsub.subscribed: 

3793 start_time = time.monotonic() 

3794 # An unbounded caller must not wait on this event uninterrupted. 

3795 # _drop_node_pubsub retires a per-node pubsub whose event is cleared 

3796 # and will never be set again - it is never resubscribed - and the 

3797 # reader can be holding that object across the drop: the round robin 

3798 # yields from a _pubsubs_generator snapshot the drop's pop does not 

3799 # reach, and get_sharded_message's target_node lookup takes no lock. 

3800 # So wake up periodically and re-check the mapping rather than park 

3801 # the single reader for good, which would withhold the messages of 

3802 # every healthy sibling in the pass too. 

3803 if timeout is None: 

3804 wait_for = SHARD_SUBSCRIBE_WAIT_TICK_SECONDS 

3805 else: 

3806 wait_for = timeout 

3807 while not pubsub.subscribed_event.wait(wait_for): 

3808 if timeout is not None: 

3809 # The connection isn't subscribed to any channels or 

3810 # patterns, so no messages are available 

3811 return None 

3812 if self._find_node_name_for_pubsub(pubsub) is None: 

3813 # Retired by a concurrent drop while we held it. Nothing can 

3814 # arrive on a subscription that is gone; let the pass move on 

3815 # to the nodes that can still deliver. 

3816 return None 

3817 # The connection was subscribed during the timeout time frame. 

3818 # The timeout should be adjusted based on the time spent 

3819 # waiting for the subscription. Only the bounded case needs it: an 

3820 # unbounded wait leaves ``timeout`` at None for the read below. 

3821 if timeout is not None: 

3822 timeout = max(0.0, timeout - (time.monotonic() - start_time)) 

3823 with self._poll_io_lock(pubsub, timeout): 

3824 # Re-check now that no writer can be mid-flight: the prelude above 

3825 # can pass and the GC then close this pubsub while holding this very 

3826 # lock, which would leave parse_response with the None connection it 

3827 # raises RuntimeError on. Only the connection is re-checked, not 

3828 # ``subscribed``: a pubsub whose last channel was detached locally 

3829 # may still have an in-flight SUNSUBSCRIBE confirmation to read. 

3830 if pubsub.connection is None: 

3831 return None 

3832 response = pubsub.parse_response(block=(timeout is None), timeout=timeout) 

3833 # get_message's truthiness test, not "is None": a health check 

3834 # reply filtered out by parse_response, or an empty bulk, is "no 

3835 # message" rather than a message to parse. 

3836 if not response: 

3837 return None 

3838 if not self._is_publish_response(response): 

3839 # Don't pass ignore_subscribe_messages here - let 

3840 # get_sharded_message handle the filtering after processing 

3841 # subscription state changes 

3842 return pubsub.handle_message(response, ignore_subscribe_messages=False) 

3843 return pubsub.handle_message(response, ignore_subscribe_messages=False) 

3844 

3845 @staticmethod 

3846 def _is_publish_response(response) -> bool: 

3847 """Whether a raw pubsub reply can make ``handle_message`` dispatch. 

3848 

3849 ``handle_message`` invokes a user handler only for 

3850 ``PUBLISH_MESSAGE_TYPES``; every other reply either does subscription 

3851 bookkeeping (``UNSUBSCRIBE_MESSAGE_TYPES``) or is a pong, and the two 

3852 branches are mutually exclusive. A non-sequence reply is the bare-PING 

3853 shape ``handle_message`` rewrites into a pong, so it cannot dispatch 

3854 either. 

3855 """ 

3856 if not isinstance(response, (list, tuple)): 

3857 return False 

3858 return str_if_bytes(response[0]) in PubSub.PUBLISH_MESSAGE_TYPES 

3859 

3860 def _schedule_topology_repair(self) -> None: 

3861 """Ask for a slots-cache refresh after a poll could not reach a node. 

3862 

3863 ``reinitialize_shard_subscriptions`` only ever runs from a slots-cache 

3864 change notification, and a node that has been rebooted or taken out of 

3865 the deployment answers ``ECONNREFUSED`` rather than ``MOVED`` - so the 

3866 read path itself has to ask, or the shard channels pinned to that node 

3867 stay there for the lifetime of the pubsub. 

3868 

3869 ``NodesManager.initialize`` serializes concurrent callers, drops nodes 

3870 that have left the topology and dispatches 

3871 ``AfterSlotsCacheRefreshEvent``, which reaches ``on_slots_changed``; run 

3872 it on the reconciliation worker so a bounded poll does not pay for a 

3873 ``CLUSTER SLOTS`` round trip, and throttle it because a node that is 

3874 down fails every poll. 

3875 

3876 ``disconnect_startup_nodes_pools=False``, unlike every other caller: the 

3877 default marks the in-use connections of the startup node that answered 

3878 ``CLUSTER SLOTS`` for reconnect and drops its idle ones, to clear a 

3879 ``READONLY`` state that may no longer apply. A per-node pubsub draws its 

3880 connection from that very pool, so on the default one unreachable shard 

3881 would recycle command and pubsub connections on a *healthy* node once 

3882 per throttle window - forcing a resubscribe and dropping in-flight 

3883 messages on a node that never failed. A fresh slot map is all this 

3884 repair asks for. The async counterpart has no such parameter and never 

3885 recycles those pools, so this is also the shape parity asks for. 

3886 """ 

3887 if not self.shard_channels: 

3888 return 

3889 now = time.monotonic() 

3890 if now < self._next_topology_repair: 

3891 return 

3892 # Unsynchronized on purpose: the read path must not queue behind a 

3893 # reconciliation pass doing per-channel network I/O, because the 

3894 # cluster-wide delivery stall that would cause is the very thing this 

3895 # repair exists to end. Two readers racing the throttle window can both 

3896 # submit; the single-worker executor runs them sequentially, so the 

3897 # loser costs one redundant CLUSTER SLOTS round trip at most (the epoch 

3898 # check in NodesManager.initialize dedups only overlapping refreshes, 

3899 # not queued ones), bounded by the throttle window. 

3900 self._next_topology_repair = now + SHARD_TOPOLOGY_REPAIR_INTERVAL_SECONDS 

3901 self._submit_reconcile_work( 

3902 partial( 

3903 self.cluster.nodes_manager.initialize, 

3904 disconnect_startup_nodes_pools=False, 

3905 ) 

3906 ) 

3907 

3908 def _handle_moved_on_read(self, pubsub, error: MovedError) -> None: 

3909 """Re-route shard channels pinned to a node that lost their slot. 

3910 

3911 ``PubSub.on_connect`` replays ``SSUBSCRIBE`` to the node its connection 

3912 is bound to, so after a slot migration that node answers ``MOVED``. 

3913 ``MovedError`` is not in ``Retry.supported_errors`` and no other code on 

3914 the read path refreshes the slots cache, so a shard channel left on a 

3915 former owner could never recover. Drop the offending channels from this 

3916 pubsub so the replay stops, forget their recorded owner so 

3917 ``reinitialize_shard_subscriptions`` does not short-circuit on an 

3918 already-advanced reverse index, then apply the redirect and reconcile. 

3919 """ 

3920 node_name = self._find_node_name_for_pubsub(pubsub) 

3921 logger.debug( 

3922 "sharded pubsub: %s no longer owns slot %s; re-routing its shard channels", 

3923 node_name, 

3924 error.slot_id, 

3925 ) 

3926 with self._shard_state_lock: 

3927 for channel in list(pubsub.shard_channels): 

3928 if key_slot(self.encoder.encode(channel)) != error.slot_id: 

3929 continue 

3930 self._detach_shard_channel(pubsub, channel) 

3931 if self._shard_channel_to_node.get(channel) == node_name: 

3932 del self._shard_channel_to_node[channel] 

3933 # The detach above can leave this pubsub with nothing subscribed - 

3934 # a node that lost its only slot answers MOVED for every channel it 

3935 # held. Retire it here rather than leave it in the mapping for a 

3936 # collector elsewhere: no SUNSUBSCRIBE confirmation will arrive for 

3937 # a channel forgotten locally, so get_sharded_message's collector 

3938 # cannot reach it, and the reconciliation pass scheduled below only 

3939 # GCs it once the worker gets to run - a whole poll cool-off later, 

3940 # at best, while an unbounded poll that reaches the empty pubsub 

3941 # first parks on it for good (see _drop_node_pubsub). 

3942 if node_name is not None and not pubsub.subscribed: 

3943 self._drop_node_pubsub(node_name, pubsub) 

3944 # move_slot applies the redirect to the slots cache and dispatches 

3945 # AfterSlotsCacheRefreshEvent, which reaches on_slots_changed. Call 

3946 # on_slots_changed unconditionally too: move_slot skips the dispatch on 

3947 # a circular MOVED, and a duplicate reconciliation pass is a no-op. 

3948 # move_slot indexes slots_cache by the redirected slot, so an 

3949 # as-yet-uncovered slot raises: log and still reconcile rather than let 

3950 # a repair attempt break a pubsub read. 

3951 try: 

3952 self.cluster.nodes_manager.move_slot(error) 

3953 except Exception as exc: 

3954 logger.debug( 

3955 "sharded pubsub: could not apply the redirect for slot %s: %s: %s", 

3956 error.slot_id, 

3957 type(exc).__name__, 

3958 exc, 

3959 ) 

3960 self.on_slots_changed() 

3961 

3962 def _pubsubs_generator(self): 

3963 # Never return: a generator that returns is exhausted for good and only 

3964 # reset() recreates this one, so a momentarily empty 

3965 # node_pubsub_mapping - reconciliation drops a per-node pubsub before 

3966 # creating its replacement - would stop the round robin permanently. 

3967 # Yield None for an empty mapping instead, which lets the caller skip 

3968 # the slot without this loop spinning on an empty list. 

3969 while True: 

3970 current_nodes = list(self.node_pubsub_mapping.values()) 

3971 if not current_nodes: 

3972 yield None 

3973 else: 

3974 yield from current_nodes 

3975 

3976 def get_sharded_message( 

3977 self, ignore_subscribe_messages=False, timeout=0.0, target_node=None 

3978 ): 

3979 """ 

3980 Get the next sharded pubsub message, or ``None`` if none is available. 

3981 

3982 Polls the per-node connections in round robin unless ``target_node`` is 

3983 given, and keeps shard channels attached to the node that currently 

3984 owns their slot: a failed poll cools that node off and asks for a 

3985 slots-cache refresh, and a ``MOVED`` reply re-routes the affected 

3986 channels to their new owner. Neither reaches the caller. A connection 

3987 failure is surfaced only when every node polled in the pass failed, so 

3988 one unreachable node does not stop delivery from its healthy siblings. 

3989 

3990 ``target_node`` opts out of that shielding: a caller that names a 

3991 single node has no sibling to protect, so connection errors propagate. 

3992 A ``MOVED`` reply is still handled rather than raised. 

3993 

3994 :param ignore_subscribe_messages: Whether to ignore subscribe messages 

3995 :param timeout: Timeout for message retrieval 

3996 :param target_node: Specific node to get message from 

3997 :return: Message dictionary or None 

3998 """ 

3999 if target_node: 

4000 # Use .get(): migration-driven cleanup in the sunsubscribe branch 

4001 # below and reset() both remove entries from node_pubsub_mapping, 

4002 # so a caller polling with target_node may race the cleanup. Match 

4003 # the async counterpart's None-handling rather than raising 

4004 # KeyError. None pubsub falls through to "no message available". 

4005 pubsub = self.node_pubsub_mapping.get(target_node.name) 

4006 if pubsub is not None: 

4007 try: 

4008 message = self._poll_node_pubsub(pubsub, timeout) 

4009 except MovedError as e: 

4010 # Same handling as the round-robin path: the caller cannot 

4011 # act on a MovedError raised out of a pubsub read, and the 

4012 # channels this node no longer owns have to be re-routed or 

4013 # they never recover. Cool off too, so a slots cache that 

4014 # cannot be corrected does not re-run the repair on every 

4015 # poll. Unlike that path, connectivity errors still 

4016 # propagate: they are swallowed there only to keep one sick 

4017 # node from starving its healthy siblings, and a caller that 

4018 # named a single node has no sibling to protect. 

4019 self._poll_cool_off[pubsub] = ( 

4020 time.monotonic() + SHARD_POLL_COOL_OFF_SECONDS 

4021 ) 

4022 self._handle_moved_on_read(pubsub, e) 

4023 message = None 

4024 else: 

4025 message = None 

4026 else: 

4027 pubsub, message = self._sharded_message_generator(timeout=timeout) 

4028 if message is None: 

4029 return None 

4030 # Only sunsubscribe mutates cluster-level shard state; bypassing the 

4031 # lock on the data-message hot path keeps smessage delivery from 

4032 # competing with the reconciliation worker for _shard_state_lock. 

4033 if str_if_bytes(message["type"]) == "sunsubscribe": 

4034 # Serialize state mutation against reinitialize_shard_subscriptions 

4035 # (worker thread). The blocking _poll_node_pubsub above 

4036 # intentionally runs outside the lock so reconciliation is not 

4037 # stalled by long polls. 

4038 with self._shard_state_lock: 

4039 if message["channel"] in self.pending_unsubscribe_shard_channels: 

4040 # User-initiated sunsubscribe: drop from cluster-level tracking. 

4041 self.pending_unsubscribe_shard_channels.remove(message["channel"]) 

4042 self.shard_channels.pop(message["channel"], None) 

4043 self._shard_channel_to_node.pop(message["channel"], None) 

4044 # Drop the per-node pubsub that delivered the confirmation once 

4045 # it no longer holds any shard subscriptions, regardless of 

4046 # whether the sunsubscribe was user-initiated or driven by 

4047 # slot-migration reconciliation (_migrate_shard_channel, which 

4048 # intentionally does not add the channel to 

4049 # pending_unsubscribe_shard_channels). This releases the 

4050 # dedicated connection that would otherwise linger. 

4051 # Identifying the receiving pubsub directly (rather than via 

4052 # the cluster's current slot map) is required after slot 

4053 # migration, where the channel's owner is no longer the node 

4054 # that received our original SSUBSCRIBE. 

4055 if pubsub is not None and not pubsub.subscribed: 

4056 name = self._find_node_name_for_pubsub(pubsub) 

4057 if name is not None: 

4058 self._drop_node_pubsub(name, pubsub) 

4059 # Mirror PubSub.handle_message: the empty-check belongs in the 

4060 # unsubscribe branch since that is the only path that can 

4061 # reduce shard_channels here. 

4062 if not self.channels and not self.patterns and not self.shard_channels: 

4063 self.subscribed_event.clear() 

4064 # Only suppress subscribe/unsubscribe messages, not data messages (smessage) 

4065 if str_if_bytes(message["type"]) in ("ssubscribe", "sunsubscribe"): 

4066 if self.ignore_subscribe_messages or ignore_subscribe_messages: 

4067 return None 

4068 return message 

4069 

4070 def ssubscribe( 

4071 self, *args: ChannelT | Subscription, **kwargs: PubSubHandler 

4072 ) -> None: 

4073 """ 

4074 Subscribe to shard channels. 

4075 

4076 Channels supplied as keyword arguments expect a channel name as the key 

4077 and a callable as the value. ``Subscription`` objects can also be 

4078 supplied positionally with an optional handler. 

4079 """ 

4080 s_channels = parse_pubsub_subscriptions(args, kwargs) 

4081 # Serialize against reinitialize_shard_subscriptions (worker thread) 

4082 # so the reverse index, shard_channels, and node_pubsub_mapping are 

4083 # not mutated concurrently. 

4084 with self._shard_state_lock: 

4085 for s_channel, handler in s_channels.items(): 

4086 node = self.cluster.get_node_from_key(s_channel) 

4087 if not node: 

4088 continue 

4089 # Lazy re-route: if this channel is already tracked against a 

4090 # different node (e.g. after a slot migration), migrate it now 

4091 # so the caller's intent is applied on the current owner. 

4092 normalized_key = next(iter(self._normalize_keys({s_channel: None}))) 

4093 old_name = self._shard_channel_to_node.get(normalized_key) 

4094 if old_name and old_name != node.name: 

4095 # Match PubSub.ssubscribe() dict.update() semantics: the 

4096 # caller's newly supplied handler (including None) always 

4097 # overrides any previously registered handler. 

4098 self._migrate_shard_channel( 

4099 normalized_key, 

4100 handler, 

4101 old_name, 

4102 node, 

4103 ) 

4104 continue 

4105 pubsub = self._get_node_pubsub(node) 

4106 with self._pubsub_io_lock(pubsub): 

4107 if handler: 

4108 pubsub.ssubscribe(Subscription(s_channel, handler)) 

4109 else: 

4110 pubsub.ssubscribe(s_channel) 

4111 self.shard_channels.update(pubsub.shard_channels) 

4112 self._shard_channel_to_node[normalized_key] = node.name 

4113 self.pending_unsubscribe_shard_channels.difference_update( 

4114 self._normalize_keys({s_channel: None}) 

4115 ) 

4116 if pubsub.subscribed and not self.subscribed: 

4117 self.subscribed_event.set() 

4118 self.health_check_response_counter = 0 

4119 

4120 def sunsubscribe(self, *args): 

4121 if args: 

4122 args = list_or_args(args[0], args[1:]) 

4123 else: 

4124 args = list(self.shard_channels) 

4125 

4126 # Serialize against reinitialize_shard_subscriptions: the reverse 

4127 # index and node_pubsub_mapping must not change between the lookup 

4128 # and the per-node sunsubscribe call below. 

4129 with self._shard_state_lock: 

4130 for s_channel in args: 

4131 normalized_key = next(iter(self._normalize_keys({s_channel: None}))) 

4132 # Route via the reverse index so we unsubscribe on the node 

4133 # that actually holds the subscription. After a slot migration 

4134 # the cluster's current owner may no longer be that node. 

4135 name = self._shard_channel_to_node.get(normalized_key) 

4136 if name and name in self.node_pubsub_mapping: 

4137 p = self.node_pubsub_mapping[name] 

4138 else: 

4139 node = self.cluster.get_node_from_key(s_channel) 

4140 if not node or node.name not in self.node_pubsub_mapping: 

4141 continue 

4142 p = self.node_pubsub_mapping[node.name] 

4143 with self._pubsub_io_lock(p): 

4144 p.sunsubscribe(s_channel) 

4145 self.pending_unsubscribe_shard_channels.update( 

4146 p.pending_unsubscribe_shard_channels 

4147 ) 

4148 

4149 def reinitialize_shard_subscriptions(self): 

4150 """ 

4151 Reconcile per-node shard subscriptions against the cluster's current 

4152 slot ownership map. For each tracked shard channel whose owning node 

4153 has changed (e.g. after CLUSTER SETSLOT / failover), sunsubscribe on 

4154 the old node's pubsub and ssubscribe on the new owner's pubsub, 

4155 preserving any registered handler. 

4156 """ 

4157 uncovered: list = [] 

4158 made_progress = False 

4159 first_migrate_error: Optional[BaseException] = None 

4160 with self._shard_state_lock: 

4161 for channel, handler in list(self.shard_channels.items()): 

4162 if channel in self.pending_unsubscribe_shard_channels: 

4163 continue 

4164 try: 

4165 new_node = self.cluster.get_node_from_key(channel) 

4166 except SlotNotCoveredError: 

4167 # Slot is transiently uncovered (mid-migration / partial 

4168 # topology refresh). Defer this channel so coverable 

4169 # siblings still reconcile this pass; we surface the 

4170 # error below so the caller (and logs) know not every 

4171 # channel was reconciled. Retry happens on the next 

4172 # slots-cache change notification. 

4173 uncovered.append(channel) 

4174 continue 

4175 old_name = self._shard_channel_to_node.get(channel) 

4176 if old_name == new_node.name: 

4177 owner = self.node_pubsub_mapping.get(new_node.name) 

4178 if owner is not None and channel in owner.shard_channels: 

4179 continue 

4180 # The reverse index names this node but the subscription is 

4181 # not there. _migrate_shard_channel detaches from the old 

4182 # owner before it advances the index, so a pass that failed 

4183 # to attach leaves the channel subscribed nowhere - and once 

4184 # ownership moves back, this short-circuit would skip it for 

4185 # the lifetime of the pubsub. Re-attach instead of trusting 

4186 # the index; there is nothing to sunsubscribe from. 

4187 old_name = None 

4188 try: 

4189 self._migrate_shard_channel(channel, handler, old_name, new_node) 

4190 made_progress = True 

4191 except (ConnectionError, TimeoutError, OSError) as e: 

4192 # Transient connectivity error while subscribing on the 

4193 # new owner (or unsubscribing on the old owner if its 

4194 # handler chose to re-raise). Do not abort reconciliation 

4195 # for sibling channels: _shard_channel_to_node was not 

4196 # advanced for this channel, so the next slots-cache 

4197 # change notification will retry it. 

4198 logger.warning( 

4199 "shard channel %r migration deferred: %s: %s", 

4200 channel, 

4201 type(e).__name__, 

4202 e, 

4203 ) 

4204 if first_migrate_error is None: 

4205 first_migrate_error = e 

4206 continue 

4207 # Garbage-collect per-node pubsubs that no longer hold any 

4208 # subscription so their connections are released. 

4209 for name, pubsub in list(self.node_pubsub_mapping.items()): 

4210 if not pubsub.subscribed: 

4211 self._drop_node_pubsub(name, pubsub) 

4212 if uncovered: 

4213 # Surface the uncovered channels so the caller (and observer 

4214 # notification path) knows reconciliation was incomplete. All 

4215 # coverable siblings have already been migrated above. 

4216 raise SlotNotCoveredError( 

4217 f"{len(uncovered)} shard channel(s) left unreconciled; " 

4218 f"slot(s) not covered by the cluster: {uncovered!r}" 

4219 ) 

4220 if first_migrate_error is not None and not made_progress: 

4221 # Every migration attempted in this pass failed transiently and 

4222 # nothing else made progress. Re-raise the first caught error 

4223 # (typically the root cause; later failures are often downstream 

4224 # symptoms of the same unreachable node) so the worker's done- 

4225 # callback surfaces a single representative failure through the 

4226 # same logger channel used for SlotNotCoveredError. Per-channel 

4227 # WARNINGs above preserve the full forensic detail. 

4228 raise first_migrate_error 

4229 

4230 def _forget_shard_channel_on_old_node(self, old_pubsub, channel, old_name): 

4231 """Drop a migrating shard channel from a node we could not tell about it. 

4232 

4233 Forget the channel locally: the caller advances the reverse index to the 

4234 new owner, so reconciliation will never revisit this channel, while 

4235 ``on_connect`` would keep replaying ``SSUBSCRIBE`` for it to this very 

4236 node on every reconnect - the server would answer ``MOVED`` and the 

4237 subscription would never work again. 

4238 """ 

4239 self._detach_shard_channel(old_pubsub, channel) 

4240 # Drop the per-node pubsub when either the old node has left the cluster 

4241 # topology - no reconnect target, so the round-robin generator must stop 

4242 # yielding a dead one, and any sibling subscription it still holds 

4243 # recovers through ``PubSub._execute``'s reconnect and ``on_connect`` 

4244 # replay - or the detach above left it with nothing subscribed. 

4245 # 

4246 # The empty case cannot be deferred to a collector elsewhere, because 

4247 # neither of the other two can reach it. ``get_sharded_message``'s 

4248 # unsubscribe branch needs a ``SUNSUBSCRIBE`` confirmation, and none will 

4249 # arrive for a channel this method forgot locally - that is the whole 

4250 # reason it is forgotten. ``reinitialize_shard_subscriptions``'s 

4251 # end-of-pass GC only runs for the reconciliation caller, while 

4252 # ``ssubscribe``'s lazy re-route reaches here without it. An empty pubsub 

4253 # left in the mapping has had its ``subscribed_event`` cleared by the 

4254 # detach, so ``_poll_node_pubsub`` waits on an event nothing will ever 

4255 # set: forever when the caller passed ``timeout=None``, and for the whole 

4256 # timeout of every pass otherwise, before a single healthy node is read. 

4257 if ( 

4258 self.cluster.get_node(node_name=old_name) is None 

4259 or not old_pubsub.subscribed 

4260 ): 

4261 self._drop_node_pubsub(old_name, old_pubsub) 

4262 

4263 def _migrate_shard_channel(self, channel, handler, old_name, new_node): 

4264 # Detach from the old per-node pubsub, best-effort: the old node may 

4265 # already be unreachable during migration / failover. 

4266 if old_name and old_name in self.node_pubsub_mapping: 

4267 old_pubsub = self.node_pubsub_mapping[old_name] 

4268 if old_name in self._unreachable_nodes: 

4269 # The reader has just failed to reach this node, so a 

4270 # ``SUNSUBSCRIBE`` cannot arrive. Skip it: the attempt would pay 

4271 # a full reconnect (and the client's whole retry budget) behind 

4272 # the reader on the same per-node io lock, all while this pass 

4273 # holds ``_shard_state_lock`` - which is what turns one departed 

4274 # node into a migration slow enough to look like a permanent 

4275 # delivery stall. 

4276 self._forget_shard_channel_on_old_node(old_pubsub, channel, old_name) 

4277 else: 

4278 try: 

4279 with self._pubsub_io_lock(old_pubsub): 

4280 old_pubsub.sunsubscribe(channel) 

4281 except (ConnectionError, TimeoutError, OSError): 

4282 # redis-py's Connection has already called ``disconnect()`` 

4283 # before raising (see Connection.read_response / 

4284 # send_packed_command with ``disconnect_on_error=True``), so 

4285 # ``old_pubsub``'s dedicated socket is gone and the 

4286 # ``SUNSUBSCRIBE`` never reached the server. 

4287 self._forget_shard_channel_on_old_node( 

4288 old_pubsub, channel, old_name 

4289 ) 

4290 # Attach to the new per-node pubsub, preserving the handler. Decode to 

4291 # a text key only when we must pass it as a kwarg (handler present). 

4292 new_pubsub = self._get_node_pubsub(new_node) 

4293 with self._pubsub_io_lock(new_pubsub): 

4294 if handler: 

4295 new_pubsub.ssubscribe(Subscription(channel, handler)) 

4296 else: 

4297 new_pubsub.ssubscribe(channel) 

4298 self.shard_channels.update(new_pubsub.shard_channels) 

4299 normalized_key = next(iter(self._normalize_keys({channel: None}))) 

4300 self._shard_channel_to_node[normalized_key] = new_node.name 

4301 self.pending_unsubscribe_shard_channels.difference_update( 

4302 self._normalize_keys({channel: None}) 

4303 ) 

4304 if new_pubsub.subscribed and not self.subscribed: 

4305 self.subscribed_event.set() 

4306 self.health_check_response_counter = 0 

4307 

4308 def on_slots_changed(self): 

4309 # Observer hook invoked by NodesManager after a slots-cache refresh. 

4310 # Schedule reconciliation on a dedicated worker thread so the caller 

4311 # (typically MovedError handling in _execute_command or the topology 

4312 # refresh thread in initialize()) is not blocked on the network I/O 

4313 # performed by reinitialize_shard_subscriptions. Mirrors the async 

4314 # path's asyncio.create_task model. No-op when there are no shard 

4315 # subscriptions to reconcile. 

4316 if not self.shard_channels: 

4317 return 

4318 # Takes no lock. This hook runs inline on whichever thread refreshed the 

4319 # topology - including a command or pubsub thread that handled an 

4320 # SMIGRATED push and still holds OSSMaintNotificationsHandler._lock. 

4321 # Waiting here for a reconciliation pass that is itself waiting for a 

4322 # per-node pubsub I/O lock held by a third thread blocked on that 

4323 # handler lock is a deadlock cycle. The async counterpart takes no lock 

4324 # here either (the event loop serializes it), so this is also the 

4325 # shape sync/async parity asks for. 

4326 self._submit_reconcile_work(self.reinitialize_shard_subscriptions) 

4327 

4328 def _submit_reconcile_work(self, work: Callable[[], Any]) -> Optional["Future"]: 

4329 """Run ``work`` on the reconciliation worker, without taking a lock. 

4330 

4331 The executor is installed by ``reset()`` and never None afterwards, so 

4332 there is nothing to create here and no creation race to serialize - see 

4333 the ``_reconcile_executor`` comment in ``__init__`` for why the callers 

4334 must not block. Only ``reset()`` swaps it, and a submit that loses that 

4335 race raises ``RuntimeError``: read the attribute once so the swap cannot 

4336 be observed half-done, and treat the rejection as "nothing to do", which 

4337 it is - ``reset()`` has already dropped the shard channels this pass 

4338 would have reconciled. 

4339 """ 

4340 executor = self._reconcile_executor 

4341 if executor is None: 

4342 # Defensive: reset() runs from PubSub.__init__, so by the time any 

4343 # caller can get here an executor is installed. 

4344 return None 

4345 try: 

4346 future = executor.submit(work) 

4347 except RuntimeError: 

4348 return None 

4349 # Consume the future's exception (if any) so it is not silently lost. 

4350 # reinitialize_shard_subscriptions surfaces SlotNotCoveredError when a 

4351 # slot is still transiently uncovered; route it through the same logger 

4352 # channel as the async path for consistent observability. 

4353 future.add_done_callback(self._log_reconcile_future_exception) 

4354 return future 

4355 

4356 @staticmethod 

4357 def _log_reconcile_future_exception(future: "Future") -> None: 

4358 if future.cancelled(): 

4359 return 

4360 exc = future.exception() 

4361 if exc is not None: 

4362 logger.error( 

4363 "shard subscription reconciliation failed: %r", exc, exc_info=exc 

4364 ) 

4365 

4366 def reset(self) -> None: 

4367 # Hold _shard_state_lock across the entire teardown so it observes 

4368 # the same mutual-exclusion discipline as ssubscribe / sunsubscribe / 

4369 # get_sharded_message / reinitialize_shard_subscriptions, which all 

4370 # mutate shard_channels, _shard_channel_to_node, and 

4371 # node_pubsub_mapping under this lock. Without it, super().reset() 

4372 # rebinds shard_channels and pending_unsubscribe_shard_channels in 

4373 # parallel with a concurrent user-thread mutation, silently dropping 

4374 # subscription intent. The reconciliation executor is swapped out at the 

4375 # end of that critical section; see the comment there for why it goes 

4376 # last. 

4377 with self._shard_state_lock: 

4378 # Tear down per-node pubsubs (parity with async aclose) so they 

4379 # don't leak their dedicated connections and don't replay stale 

4380 # shard_channels via PubSub.on_connect on a subsequent reconnect. 

4381 # Errors are swallowed because reset() is also a fallback path 

4382 # from __del__; we cannot let one buggy per-node pubsub mask the 

4383 # rest of the teardown. 

4384 # The per-node I/O lock keeps the socket from being torn down 

4385 # beneath a concurrent bounded poll parked in parse_response. It is 

4386 # an RLock, so re-entry from this thread is fine; contention is with 

4387 # another thread's bounded poll and is bounded by that poll's 

4388 # timeout (an unbounded poll holds nullcontext() - see 

4389 # _poll_io_lock). reset() is also the __del__ fallback path, where 

4390 # the try below does not cover a blocking acquire - that boundedness 

4391 # is what makes taking the lock here safe. 

4392 for pubsub in self.node_pubsub_mapping.values(): 

4393 try: 

4394 with self._pubsub_io_lock(pubsub): 

4395 pubsub.reset() 

4396 except Exception: 

4397 pass 

4398 # Drop the now-dead per-node pubsubs from the mapping so the 

4399 # round-robin in _pubsubs_generator / _sharded_message_generator 

4400 # cannot yield them between teardown and re-subscription. 

4401 self.node_pubsub_mapping.clear() 

4402 self._unreachable_nodes.clear() 

4403 # Drop the throttle window too: a reused pubsub that keeps a 

4404 # deadline armed before the teardown would skip the first repair 

4405 # after resubscribing, delaying the move of its shard channels 

4406 # off a node that is already gone. 

4407 self._next_topology_repair = 0.0 

4408 # _pubsubs_generator captures node_pubsub_mapping.values() into 

4409 # a local list inside ``yield from``; clearing the mapping does 

4410 # not reach references already held by that captured snapshot, 

4411 # so a generator suspended mid-yield-from would still surface 

4412 # the now-reset() per-node pubsubs after re-subscription. 

4413 # Recreate it to drop the captured list. type(self) bypasses 

4414 # the instance-level self-shadow established at __init__ 

4415 # (self._pubsubs_generator = self._pubsubs_generator()). 

4416 self._pubsubs_generator = type(self)._pubsubs_generator(self) 

4417 super().reset() 

4418 self._shard_channel_to_node = {} 

4419 # Swap in a fresh reconciliation executor and retire the old one. 

4420 # Installing the replacement is what lets the scheduling sites stay 

4421 # lock-free (see __init__); it costs nothing until something is 

4422 # submitted, because a ThreadPoolExecutor spawns its worker on the 

4423 # first submit(). Done last, after super().reset() has emptied 

4424 # shard_channels, so a concurrent on_slots_changed either loses the 

4425 # swap and is rejected or finds nothing left to reconcile. 

4426 # cancel_futures drops queued reconciliation work; a pass already 

4427 # running is serialized against us by _shard_state_lock, and 

4428 # shutdown(wait=False) avoids waiting on the worker thread's join. 

4429 retired = self._reconcile_executor 

4430 self._reconcile_executor = ThreadPoolExecutor( 

4431 max_workers=1, 

4432 thread_name_prefix="redis-cluster-pubsub-reconcile", 

4433 ) 

4434 if retired is not None: 

4435 retired.shutdown(wait=False, cancel_futures=True) 

4436 

4437 def get_redis_connection(self): 

4438 """ 

4439 Get the Redis connection of the pubsub connected node. 

4440 """ 

4441 if self.node is not None: 

4442 return self.node.redis_connection 

4443 

4444 def disconnect(self): 

4445 """ 

4446 Disconnect the pubsub connection. 

4447 """ 

4448 if self.connection: 

4449 self.connection.disconnect() 

4450 for pubsub in self.node_pubsub_mapping.values(): 

4451 if pubsub.connection: 

4452 pubsub.connection.disconnect() 

4453 

4454 

4455class ClusterPipeline(RedisCluster): 

4456 """ 

4457 Support for Redis pipeline 

4458 in cluster mode 

4459 """ 

4460 

4461 ERRORS_ALLOW_RETRY = ( 

4462 ConnectionError, 

4463 TimeoutError, 

4464 MovedError, 

4465 AskError, 

4466 TryAgainError, 

4467 ) 

4468 

4469 NO_SLOTS_COMMANDS = {"UNWATCH"} 

4470 IMMEDIATE_EXECUTE_COMMANDS = {"WATCH", "UNWATCH"} 

4471 UNWATCH_COMMANDS = {"DISCARD", "EXEC", "UNWATCH"} 

4472 

4473 @deprecated_args( 

4474 args_to_warn=[ 

4475 "cluster_error_retry_attempts", 

4476 ], 

4477 reason="Please configure the 'retry' object instead", 

4478 version="6.0.0", 

4479 ) 

4480 def __init__( 

4481 self, 

4482 nodes_manager: "NodesManager", 

4483 commands_parser: "CommandsParser", 

4484 result_callbacks: Optional[Dict[str, Callable]] = None, 

4485 cluster_response_callbacks: Optional[Dict[str, Callable]] = None, 

4486 startup_nodes: Optional[List["ClusterNode"]] = None, 

4487 read_from_replicas: bool = False, 

4488 load_balancing_strategy: Optional[LoadBalancingStrategy] = None, 

4489 cluster_error_retry_attempts: int = DEFAULT_RETRY_COUNT, 

4490 reinitialize_steps: int = 5, 

4491 retry: Optional[Retry] = None, 

4492 lock=None, 

4493 transaction=False, 

4494 policy_resolver: Optional[PolicyResolver] = None, 

4495 event_dispatcher: Optional["EventDispatcher"] = None, 

4496 metadata_resolver: Optional[MetadataResolver] = None, 

4497 **kwargs, 

4498 ): 

4499 """ """ 

4500 self.command_stack = [] 

4501 self.nodes_manager = nodes_manager 

4502 # Share the parent cluster's HIMPORT registry (held on the NodesManager and 

4503 # referenced by every node pool). The inherited himport_prepare/discard/ 

4504 # discard_all mutate this one object, so a fieldset declared on the pipeline is 

4505 # visible to the batched himport_set pre-flight exactly as on the parent client. 

4506 self._himport_registry = nodes_manager.himport_registry 

4507 self.commands_parser = commands_parser 

4508 self.refresh_table_asap = False 

4509 self.result_callbacks = ( 

4510 result_callbacks or self.__class__.RESULT_CALLBACKS.copy() 

4511 ) 

4512 self.startup_nodes = startup_nodes if startup_nodes else [] 

4513 self.read_from_replicas = read_from_replicas 

4514 self.load_balancing_strategy = load_balancing_strategy 

4515 self.command_flags = self.__class__.COMMAND_FLAGS.copy() 

4516 self.cluster_response_callbacks = cluster_response_callbacks 

4517 self.reinitialize_counter = 0 

4518 self.reinitialize_steps = reinitialize_steps 

4519 if retry is not None: 

4520 self.retry = retry 

4521 else: 

4522 self.retry = Retry( 

4523 backoff=ExponentialWithJitterBackoff( 

4524 base=DEFAULT_RETRY_BASE, cap=DEFAULT_RETRY_CAP 

4525 ), 

4526 retries=cluster_error_retry_attempts, 

4527 ) 

4528 

4529 self.encoder = Encoder( 

4530 kwargs.get("encoding", "utf-8"), 

4531 kwargs.get("encoding_errors", "strict"), 

4532 kwargs.get("decode_responses", False), 

4533 ) 

4534 if lock is None: 

4535 lock = threading.RLock() 

4536 self._lock = lock 

4537 self.parent_execute_command = super().execute_command 

4538 self._execution_strategy: ExecutionStrategy = ( 

4539 PipelineStrategy(self) if not transaction else TransactionStrategy(self) 

4540 ) 

4541 

4542 # For backward compatibility, mapping from existing policies to new one 

4543 self._command_flags_mapping: dict[str, Union[RequestPolicy, ResponsePolicy]] = { 

4544 self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS, 

4545 self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS, 

4546 self.__class__.ALL_NODES: RequestPolicy.ALL_NODES, 

4547 self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS, 

4548 self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE, 

4549 SLOT_ID: RequestPolicy.DEFAULT_KEYED, 

4550 } 

4551 

4552 self._policies_callback_mapping: dict[ 

4553 Union[RequestPolicy, ResponsePolicy], Callable 

4554 ] = { 

4555 RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [ 

4556 self.get_keyless_target_node(command_name) 

4557 ], 

4558 RequestPolicy.DEFAULT_KEYED: lambda command, 

4559 *args: self.get_nodes_from_slot(command, *args), 

4560 RequestPolicy.DEFAULT_NODE: lambda: [self.get_default_node()], 

4561 RequestPolicy.ALL_SHARDS: self.get_primaries, 

4562 RequestPolicy.ALL_NODES: self.get_nodes, 

4563 RequestPolicy.ALL_REPLICAS: self.get_replicas, 

4564 RequestPolicy.MULTI_SHARD: lambda *args, 

4565 **kwargs: self._split_multi_shard_command(*args, **kwargs), 

4566 RequestPolicy.SPECIAL: self.get_special_nodes, 

4567 ResponsePolicy.DEFAULT_KEYLESS: lambda res: res, 

4568 ResponsePolicy.DEFAULT_KEYED: lambda res: res, 

4569 } 

4570 

4571 # ``RedisCluster.pipeline`` passes the client's own resolvers, so a pipeline routes by 

4572 # whatever the client routes by. Only a pipeline built directly, without either, falls 

4573 # back to the static default. Precedence between the two mirrors ``RedisCluster`` - 

4574 # see the note there. 

4575 if metadata_resolver is None: 

4576 self._metadata_resolver: MetadataResolver = StaticMetadataResolver() 

4577 else: 

4578 self._metadata_resolver = metadata_resolver 

4579 

4580 if policy_resolver is None: 

4581 self._policy_resolver: PolicyResolver = StaticPolicyResolver( 

4582 metadata_resolver=self._metadata_resolver 

4583 ) 

4584 else: 

4585 self._policy_resolver = policy_resolver 

4586 

4587 if event_dispatcher is None: 

4588 self._event_dispatcher = EventDispatcher() 

4589 else: 

4590 self._event_dispatcher = event_dispatcher 

4591 

4592 def __repr__(self): 

4593 """ """ 

4594 return f"{type(self).__name__}" 

4595 

4596 def __enter__(self): 

4597 """ """ 

4598 return self 

4599 

4600 def __exit__(self, exc_type, exc_value, traceback): 

4601 """ """ 

4602 self.reset() 

4603 

4604 def __del__(self): 

4605 try: 

4606 self.reset() 

4607 except Exception: 

4608 pass 

4609 

4610 def __len__(self): 

4611 """ """ 

4612 return len(self._execution_strategy.command_queue) 

4613 

4614 def __bool__(self): 

4615 "Pipeline instances should always evaluate to True on Python 3+" 

4616 return True 

4617 

4618 def execute_command(self, *args, **kwargs): 

4619 """ 

4620 Wrapper function for pipeline_execute_command 

4621 """ 

4622 return self._execution_strategy.execute_command(*args, **kwargs) 

4623 

4624 def pipeline_execute_command(self, *args, **options): 

4625 """ 

4626 Stage a command to be executed when execute() is next called 

4627 

4628 Returns the current Pipeline object back so commands can be 

4629 chained together, such as: 

4630 

4631 pipe = pipe.set('foo', 'bar').incr('baz').decr('bang') 

4632 

4633 At some other point, you can then run: pipe.execute(), 

4634 which will execute all commands queued in the pipe. 

4635 """ 

4636 return self._execution_strategy.execute_command(*args, **options) 

4637 

4638 def annotate_exception(self, exception, number, command): 

4639 """ 

4640 Provides extra context to the exception prior to it being handled 

4641 """ 

4642 self._execution_strategy.annotate_exception(exception, number, command) 

4643 

4644 def execute(self, raise_on_error: bool = True) -> List[Any]: 

4645 """ 

4646 Execute all the commands in the current pipeline 

4647 """ 

4648 

4649 try: 

4650 return self._execution_strategy.execute(raise_on_error) 

4651 finally: 

4652 self.reset() 

4653 

4654 def reset(self): 

4655 """ 

4656 Reset back to empty pipeline. 

4657 """ 

4658 self._execution_strategy.reset() 

4659 

4660 def send_cluster_commands( 

4661 self, stack, raise_on_error=True, allow_redirections=True 

4662 ): 

4663 return self._execution_strategy.send_cluster_commands( 

4664 stack, raise_on_error=raise_on_error, allow_redirections=allow_redirections 

4665 ) 

4666 

4667 def exists(self, *keys): 

4668 return self._execution_strategy.exists(*keys) 

4669 

4670 def eval(self): 

4671 """ """ 

4672 return self._execution_strategy.eval() 

4673 

4674 def multi(self): 

4675 """ 

4676 Start a transactional block of the pipeline after WATCH commands 

4677 are issued. End the transactional block with `execute`. 

4678 """ 

4679 self._execution_strategy.multi() 

4680 

4681 def load_scripts(self): 

4682 """ """ 

4683 self._execution_strategy.load_scripts() 

4684 

4685 def discard(self): 

4686 """ """ 

4687 self._execution_strategy.discard() 

4688 

4689 def watch(self, *names): 

4690 """Watches the values at keys ``names``""" 

4691 self._execution_strategy.watch(*names) 

4692 

4693 def unwatch(self): 

4694 """Unwatches all previously specified keys""" 

4695 self._execution_strategy.unwatch() 

4696 

4697 def script_load_for_pipeline(self, *args, **kwargs): 

4698 self._execution_strategy.script_load_for_pipeline(*args, **kwargs) 

4699 

4700 def delete(self, *names): 

4701 self._execution_strategy.delete(*names) 

4702 

4703 def unlink(self, *names): 

4704 self._execution_strategy.unlink(*names) 

4705 

4706 

4707def block_pipeline_command(name: str) -> Callable[..., Any]: 

4708 """ 

4709 Prints error because some pipelined commands should 

4710 be blocked when running in cluster-mode 

4711 """ 

4712 

4713 def inner(*args, **kwargs): 

4714 raise RedisClusterException( 

4715 f"ERROR: Calling pipelined function {name} is blocked " 

4716 f"when running redis in cluster mode..." 

4717 ) 

4718 

4719 return inner 

4720 

4721 

4722def is_zero_key_eval_command(*args) -> bool: 

4723 """ 

4724 True for EVAL/EVALSHA with numkeys=0 (any primary). 

4725 """ 

4726 if len(args) < 3: 

4727 return False 

4728 if str(args[0]).upper() not in ("EVAL", "EVALSHA"): 

4729 return False 

4730 try: 

4731 return int(args[2]) == 0 

4732 except (TypeError, ValueError): 

4733 return False 

4734 

4735 

4736# Blocked pipeline commands 

4737PIPELINE_BLOCKED_COMMANDS = ( 

4738 "BGREWRITEAOF", 

4739 "BGSAVE", 

4740 "BITOP", 

4741 "BRPOPLPUSH", 

4742 "CLIENT GETNAME", 

4743 "CLIENT KILL", 

4744 "CLIENT LIST", 

4745 "CLIENT SETNAME", 

4746 "CLIENT", 

4747 "CONFIG GET", 

4748 "CONFIG RESETSTAT", 

4749 "CONFIG REWRITE", 

4750 "CONFIG SET", 

4751 "CONFIG", 

4752 "DBSIZE", 

4753 "ECHO", 

4754 "FLUSHALL", 

4755 "FLUSHDB", 

4756 "INFO", 

4757 "KEYS", 

4758 "LASTSAVE", 

4759 "MGET", 

4760 "MGET NONATOMIC", 

4761 "MOVE", 

4762 "MSET", 

4763 "MSETEX", 

4764 "MSET NONATOMIC", 

4765 "MSETNX", 

4766 "PFCOUNT", 

4767 "PFMERGE", 

4768 "PING", 

4769 "PUBLISH", 

4770 "RANDOMKEY", 

4771 "READONLY", 

4772 "READWRITE", 

4773 "RENAME", 

4774 "RENAMENX", 

4775 "RPOPLPUSH", 

4776 "SAVE", 

4777 "SCAN", 

4778 "SCRIPT EXISTS", 

4779 "SCRIPT FLUSH", 

4780 "SCRIPT KILL", 

4781 "SCRIPT LOAD", 

4782 "SCRIPT", 

4783 "SDIFF", 

4784 "SDIFFSTORE", 

4785 "SENTINEL GET MASTER ADDR BY NAME", 

4786 "SENTINEL MASTER", 

4787 "SENTINEL MASTERS", 

4788 "SENTINEL MONITOR", 

4789 "SENTINEL REMOVE", 

4790 "SENTINEL SENTINELS", 

4791 "SENTINEL SET", 

4792 "SENTINEL SLAVES", 

4793 "SENTINEL", 

4794 "SHUTDOWN", 

4795 "SINTER", 

4796 "SINTERSTORE", 

4797 "SLAVEOF", 

4798 "SLOWLOG GET", 

4799 "SLOWLOG LEN", 

4800 "SLOWLOG RESET", 

4801 "SLOWLOG", 

4802 "SMOVE", 

4803 "SORT", 

4804 "SUNION", 

4805 "SUNIONSTORE", 

4806 "TIME", 

4807) 

4808for command in PIPELINE_BLOCKED_COMMANDS: 

4809 command = command.replace(" ", "_").lower() 

4810 

4811 setattr(ClusterPipeline, command, block_pipeline_command(command)) 

4812 

4813# client_list_iter has no wire command of its own to add to 

4814# PIPELINE_BLOCKED_COMMANDS - it sends CLIENT LIST, blocked above under its 

4815# own name - so block it explicitly here too, or it would fall through to 

4816# the inherited implementation and queue CLIENT LIST like a real pipelined 

4817# command instead of raising. 

4818setattr(ClusterPipeline, "client_list_iter", block_pipeline_command("client_list_iter")) 

4819 

4820 

4821class PipelineCommand: 

4822 """ """ 

4823 

4824 def __init__(self, args, options=None, position=None): 

4825 self.args = args 

4826 if options is None: 

4827 options = {} 

4828 self.options = options 

4829 self.position = position 

4830 self.result = None 

4831 self.node = None 

4832 self.asking = False 

4833 # Either record type: a policy resolver serves ``CommandPolicies``, while the 

4834 # fallbacks below reuse the shared ``CommandMetadata`` defaults. Only the two routing 

4835 # policies, which both carry, are ever read. 

4836 self.command_policies: Optional[Union[CommandPolicies, CommandMetadata]] = None 

4837 

4838 

4839class NodeCommands: 

4840 """ """ 

4841 

4842 def __init__( 

4843 self, parse_response, connection_pool: ConnectionPool, connection: Connection 

4844 ): 

4845 """ """ 

4846 self.parse_response = parse_response 

4847 self.connection_pool = connection_pool 

4848 self.connection = connection 

4849 self.commands = [] 

4850 

4851 def append(self, c): 

4852 """ """ 

4853 self.commands.append(c) 

4854 

4855 def write(self): 

4856 """ 

4857 Code borrowed from Redis so it can be fixed 

4858 """ 

4859 connection = self.connection 

4860 commands = self.commands 

4861 

4862 # We are going to clobber the commands with the write, so go ahead 

4863 # and ensure that nothing is sitting there from a previous run. 

4864 for c in commands: 

4865 c.result = None 

4866 

4867 # build up all commands into a single request to increase network perf 

4868 # send all the commands and catch connection and timeout errors. 

4869 try: 

4870 connection.send_packed_command( 

4871 connection.pack_commands([c.args for c in commands]) 

4872 ) 

4873 except (ConnectionError, TimeoutError) as e: 

4874 for c in commands: 

4875 c.result = e 

4876 

4877 def read(self): 

4878 """ """ 

4879 connection = self.connection 

4880 for c in self.commands: 

4881 # if there is a result on this command, 

4882 # it means we ran into an exception 

4883 # like a connection error. Trying to parse 

4884 # a response on a connection that 

4885 # is no longer open will result in a 

4886 # connection error raised by redis-py. 

4887 # but redis-py doesn't check in parse_response 

4888 # that the sock object is 

4889 # still set and if you try to 

4890 # read from a closed connection, it will 

4891 # result in an AttributeError because 

4892 # it will do a readline() call on None. 

4893 # This can have all kinds of nasty side-effects. 

4894 # Treating this case as a connection error 

4895 # is fine because it will dump 

4896 # the connection object back into the 

4897 # pool and on the next write, it will 

4898 # explicitly open the connection and all will be well. 

4899 if c.result is None: 

4900 try: 

4901 c.result = self.parse_response(connection, c.args[0], **c.options) 

4902 except (ConnectionError, TimeoutError) as e: 

4903 for c in self.commands: 

4904 c.result = e 

4905 return 

4906 except RedisError: 

4907 c.result = sys.exc_info()[1] 

4908 

4909 

4910class ExecutionStrategy(ABC): 

4911 @property 

4912 @abstractmethod 

4913 def command_queue(self): 

4914 pass 

4915 

4916 @abstractmethod 

4917 def execute_command(self, *args, **kwargs): 

4918 """ 

4919 Execution flow for current execution strategy. 

4920 

4921 See: ClusterPipeline.execute_command() 

4922 """ 

4923 pass 

4924 

4925 @abstractmethod 

4926 def annotate_exception(self, exception, number, command): 

4927 """ 

4928 Annotate exception according to current execution strategy. 

4929 

4930 See: ClusterPipeline.annotate_exception() 

4931 """ 

4932 pass 

4933 

4934 @abstractmethod 

4935 def pipeline_execute_command(self, *args, **options): 

4936 """ 

4937 Pipeline execution flow for current execution strategy. 

4938 

4939 See: ClusterPipeline.pipeline_execute_command() 

4940 """ 

4941 pass 

4942 

4943 @abstractmethod 

4944 def execute(self, raise_on_error: bool = True) -> List[Any]: 

4945 """ 

4946 Executes current execution strategy. 

4947 

4948 See: ClusterPipeline.execute() 

4949 """ 

4950 pass 

4951 

4952 @abstractmethod 

4953 def send_cluster_commands( 

4954 self, stack, raise_on_error=True, allow_redirections=True 

4955 ): 

4956 """ 

4957 Sends commands according to current execution strategy. 

4958 

4959 See: ClusterPipeline.send_cluster_commands() 

4960 """ 

4961 pass 

4962 

4963 @abstractmethod 

4964 def reset(self): 

4965 """ 

4966 Resets current execution strategy. 

4967 

4968 See: ClusterPipeline.reset() 

4969 """ 

4970 pass 

4971 

4972 @abstractmethod 

4973 def exists(self, *keys): 

4974 pass 

4975 

4976 @abstractmethod 

4977 def eval(self): 

4978 pass 

4979 

4980 @abstractmethod 

4981 def multi(self): 

4982 """ 

4983 Starts transactional context. 

4984 

4985 See: ClusterPipeline.multi() 

4986 """ 

4987 pass 

4988 

4989 @abstractmethod 

4990 def load_scripts(self): 

4991 pass 

4992 

4993 @abstractmethod 

4994 def watch(self, *names): 

4995 pass 

4996 

4997 @abstractmethod 

4998 def unwatch(self): 

4999 """ 

5000 Unwatches all previously specified keys 

5001 

5002 See: ClusterPipeline.unwatch() 

5003 """ 

5004 pass 

5005 

5006 @abstractmethod 

5007 def script_load_for_pipeline(self, *args, **kwargs): 

5008 pass 

5009 

5010 @abstractmethod 

5011 def delete(self, *names): 

5012 """ 

5013 "Delete a key specified by ``names``" 

5014 

5015 See: ClusterPipeline.delete() 

5016 """ 

5017 pass 

5018 

5019 @abstractmethod 

5020 def unlink(self, *names): 

5021 """ 

5022 "Unlink a key specified by ``names``" 

5023 

5024 See: ClusterPipeline.unlink() 

5025 """ 

5026 pass 

5027 

5028 @abstractmethod 

5029 def discard(self): 

5030 pass 

5031 

5032 

5033class AbstractStrategy(ExecutionStrategy): 

5034 def __init__( 

5035 self, 

5036 pipe: ClusterPipeline, 

5037 ): 

5038 self._command_queue: List[PipelineCommand] = [] 

5039 self._pipe = pipe 

5040 self._nodes_manager = self._pipe.nodes_manager 

5041 

5042 @property 

5043 def command_queue(self): 

5044 return self._command_queue 

5045 

5046 @command_queue.setter 

5047 def command_queue(self, queue: List[PipelineCommand]): 

5048 self._command_queue = queue 

5049 

5050 @abstractmethod 

5051 def execute_command(self, *args, **kwargs): 

5052 pass 

5053 

5054 def pipeline_execute_command(self, *args, **options): 

5055 self._command_queue.append( 

5056 PipelineCommand(args, options, len(self._command_queue)) 

5057 ) 

5058 return self._pipe 

5059 

5060 def _himport_prepare_pipeline(self, redis_node, conn, commands): 

5061 """Delegate to the shared sync HIMPORT executor.""" 

5062 _himport_exec.prepare_pipeline(redis_node, conn, [args for args, _ in commands]) 

5063 

5064 @abstractmethod 

5065 def execute(self, raise_on_error: bool = True) -> List[Any]: 

5066 pass 

5067 

5068 @abstractmethod 

5069 def send_cluster_commands( 

5070 self, stack, raise_on_error=True, allow_redirections=True 

5071 ): 

5072 pass 

5073 

5074 @abstractmethod 

5075 def reset(self): 

5076 pass 

5077 

5078 def exists(self, *keys): 

5079 return self.execute_command("EXISTS", *keys) 

5080 

5081 def eval(self): 

5082 """ """ 

5083 raise RedisClusterException("method eval() is not implemented") 

5084 

5085 def load_scripts(self): 

5086 """ """ 

5087 raise RedisClusterException("method load_scripts() is not implemented") 

5088 

5089 def script_load_for_pipeline(self, *args, **kwargs): 

5090 """ """ 

5091 raise RedisClusterException( 

5092 "method script_load_for_pipeline() is not implemented" 

5093 ) 

5094 

5095 def annotate_exception(self, exception, number, command): 

5096 """ 

5097 Provides extra context to the exception prior to it being handled 

5098 """ 

5099 cmd = " ".join(map(safe_str, command)) 

5100 msg = ( 

5101 f"Command # {number} ({truncate_text(cmd)}) of pipeline " 

5102 f"caused error: {exception.args[0]}" 

5103 ) 

5104 exception.args = (msg,) + exception.args[1:] 

5105 

5106 

5107class PipelineStrategy(AbstractStrategy): 

5108 def __init__(self, pipe: ClusterPipeline): 

5109 super().__init__(pipe) 

5110 self.command_flags = pipe.command_flags 

5111 

5112 def execute_command(self, *args, **kwargs): 

5113 return self.pipeline_execute_command(*args, **kwargs) 

5114 

5115 def _raise_first_error(self, stack, start_time): 

5116 """ 

5117 Raise the first exception on the stack 

5118 """ 

5119 for c in stack: 

5120 r = c.result 

5121 if isinstance(r, Exception): 

5122 self.annotate_exception(r, c.position + 1, c.args) 

5123 

5124 record_operation_duration( 

5125 command_name="PIPELINE", 

5126 duration_seconds=time.monotonic() - start_time, 

5127 error=r, 

5128 ) 

5129 

5130 raise r 

5131 

5132 def execute(self, raise_on_error: bool = True) -> List[Any]: 

5133 stack = self._command_queue 

5134 if not stack: 

5135 return [] 

5136 

5137 try: 

5138 return self.send_cluster_commands(stack, raise_on_error) 

5139 finally: 

5140 self.reset() 

5141 

5142 def reset(self): 

5143 """ 

5144 Reset back to empty pipeline. 

5145 """ 

5146 self._command_queue = [] 

5147 

5148 def send_cluster_commands( 

5149 self, stack, raise_on_error=True, allow_redirections=True 

5150 ): 

5151 """ 

5152 Wrapper for RedisCluster.ERRORS_ALLOW_RETRY errors handling. 

5153 

5154 If one of the retryable exceptions has been thrown we assume that: 

5155 - connection_pool was disconnected 

5156 - connection_pool was reset 

5157 - refresh_table_asap set to True 

5158 

5159 It will try the number of times specified by 

5160 the retries in config option "self.retry" 

5161 which defaults to 10 unless manually configured. 

5162 

5163 If it reaches the number of times, the command will 

5164 raises ClusterDownException. 

5165 """ 

5166 if not stack: 

5167 return [] 

5168 retry_attempts = self._pipe.retry.get_retries() 

5169 while True: 

5170 try: 

5171 return self._send_cluster_commands( 

5172 stack, 

5173 raise_on_error=raise_on_error, 

5174 allow_redirections=allow_redirections, 

5175 ) 

5176 except RedisCluster.ERRORS_ALLOW_RETRY as e: 

5177 if retry_attempts > 0: 

5178 # Try again with the new cluster setup. All other errors 

5179 # should be raised. 

5180 retry_attempts -= 1 

5181 pass 

5182 else: 

5183 raise e 

5184 

5185 def _send_cluster_commands( 

5186 self, stack, raise_on_error=True, allow_redirections=True 

5187 ): 

5188 """ 

5189 Send a bunch of cluster commands to the redis cluster. 

5190 

5191 `allow_redirections` If the pipeline should follow 

5192 `ASK` & `MOVED` responses automatically. If set 

5193 to false it will raise RedisClusterException. 

5194 """ 

5195 # the first time sending the commands we send all of 

5196 # the commands that were queued up. 

5197 # if we have to run through it again, we only retry 

5198 # the commands that failed. 

5199 attempt = sorted(stack, key=lambda x: x.position) 

5200 is_default_node = False 

5201 # build a list of node objects based on node names we need to 

5202 nodes: dict[str, NodeCommands] = {} 

5203 # node objects keyed by name, so each node's connection can be pre-flighted 

5204 # for HIMPORT SET (PREPARE) before the batched write. 

5205 node_objs: dict = {} 

5206 nodes_written = 0 

5207 nodes_read = 0 

5208 

5209 try: 

5210 # as we move through each command that still needs to be processed, 

5211 # we figure out the slot number that command maps to, then from 

5212 # the slot determine the node. 

5213 for c in attempt: 

5214 command_policies = self._pipe._policy_resolver.resolve( 

5215 c.args[0].lower() 

5216 ) 

5217 # refer to our internal node -> slot table that 

5218 # tells us where a given command should route to. 

5219 # (it might be possible we have a cached node that no longer 

5220 # exists in the cluster, which is why we do this in a loop) 

5221 passed_targets = c.options.pop("target_nodes", None) 

5222 if passed_targets and not self._is_nodes_flag(passed_targets): 

5223 target_nodes = self._parse_target_nodes(passed_targets) 

5224 

5225 if not command_policies: 

5226 command_policies = _DEFAULT_KEYLESS_METADATA 

5227 else: 

5228 if not command_policies: 

5229 command = c.args[0].upper() 

5230 if ( 

5231 len(c.args) >= 2 

5232 and f"{c.args[0]} {c.args[1]}".upper() 

5233 in self._pipe.command_flags 

5234 ): 

5235 command = f"{c.args[0]} {c.args[1]}".upper() 

5236 

5237 # We only could resolve key properties if command is not 

5238 # in a list of pre-defined request policies 

5239 command_flag = self.command_flags.get(command) 

5240 if not command_flag: 

5241 # Fallback to default policy. 

5242 # EVAL/EVALSHA must not use _get_command_keys(): Redis 

5243 # <7 breaks on COMMAND GETKEYS when numkeys is 0. 

5244 # Other unflagged commands keep the keyless fallback. 

5245 if command in ("EVAL", "EVALSHA"): 

5246 command_policies = _DEFAULT_KEYED_METADATA 

5247 else: 

5248 if not self._pipe.get_default_node(): 

5249 keys = None 

5250 else: 

5251 keys = self._pipe._get_command_keys(*c.args) 

5252 if not keys or len(keys) == 0: 

5253 command_policies = _DEFAULT_KEYLESS_METADATA 

5254 else: 

5255 command_policies = _DEFAULT_KEYED_METADATA 

5256 else: 

5257 if command_flag in self._pipe._command_flags_mapping: 

5258 command_policies = _METADATA_BY_REQUEST_POLICY[ 

5259 self._pipe._command_flags_mapping[command_flag] 

5260 ] 

5261 else: 

5262 command_policies = _DEFAULT_KEYLESS_METADATA 

5263 

5264 target_nodes = self._determine_nodes( 

5265 *c.args, 

5266 request_policy=command_policies.request_policy, 

5267 node_flag=passed_targets, 

5268 ) 

5269 if not target_nodes: 

5270 raise RedisClusterException( 

5271 f"No targets were found to execute {c.args} command on" 

5272 ) 

5273 c.command_policies = command_policies 

5274 if len(target_nodes) > 1: 

5275 raise RedisClusterException( 

5276 f"Too many targets for command {c.args}" 

5277 ) 

5278 

5279 node = target_nodes[0] 

5280 if node == self._pipe.get_default_node(): 

5281 is_default_node = True 

5282 

5283 # now that we know the name of the node 

5284 # ( it's just a string in the form of host:port ) 

5285 # we can build a list of commands for each node. 

5286 node_name = node.name 

5287 if node_name not in nodes: 

5288 redis_node = self._pipe.get_redis_connection(node) 

5289 try: 

5290 connection = get_connection(redis_node) 

5291 except (ConnectionError, TimeoutError): 

5292 # Release any connections we've already acquired before clearing nodes 

5293 for n in nodes.values(): 

5294 n.connection_pool.release(n.connection) 

5295 # Connection retries are being handled in the node's 

5296 # Retry object. Reinitialize the node -> slot table. 

5297 self._nodes_manager.initialize() 

5298 if is_default_node: 

5299 self._pipe.replace_default_node() 

5300 nodes = {} 

5301 raise 

5302 nodes[node_name] = NodeCommands( 

5303 redis_node.parse_response, 

5304 redis_node.connection_pool, 

5305 connection, 

5306 ) 

5307 node_objs[node_name] = node 

5308 nodes[node_name].append(c) 

5309 

5310 # send the commands in sequence. 

5311 # we write to all the open sockets for each node first, 

5312 # before reading anything 

5313 # this allows us to flush all the requests out across the 

5314 # network 

5315 # so that we can read them from different sockets as they come back. 

5316 # we don't multiplex on the sockets as they come available, 

5317 # but that shouldn't make too much difference. 

5318 

5319 # HIMPORT SETs in the batch need their fieldsets prepared on each 

5320 # node's connection first; the packed write bypasses the per-command 

5321 # lazy prepare, so pre-flight the PREPARE (once per node) here. 

5322 for node_name, n in nodes.items(): 

5323 redis_node = self._pipe.get_redis_connection(node_objs[node_name]) 

5324 self._himport_prepare_pipeline( 

5325 redis_node, n.connection, [(c.args, c.options) for c in n.commands] 

5326 ) 

5327 

5328 # Start timing for observability 

5329 start_time = time.monotonic() 

5330 

5331 node_commands = nodes.values() 

5332 for n in node_commands: 

5333 nodes_written += 1 

5334 n.write() 

5335 

5336 for n in node_commands: 

5337 n.read() 

5338 

5339 # Find the first error in this node's commands, if any 

5340 node_error = None 

5341 for cmd in n.commands: 

5342 if isinstance(cmd.result, Exception): 

5343 node_error = cmd.result 

5344 break 

5345 

5346 record_operation_duration( 

5347 command_name="PIPELINE", 

5348 duration_seconds=time.monotonic() - start_time, 

5349 server_address=n.connection.host, 

5350 server_port=n.connection.port, 

5351 db_namespace=str(n.connection.db), 

5352 error=node_error, 

5353 ) 

5354 nodes_read += 1 

5355 finally: 

5356 # release all the redis connections we allocated earlier 

5357 # back into the connection pool. 

5358 # if the connection is dirty (that is: we've written 

5359 # commands to it, but haven't read the responses), we need 

5360 # to close the connection before returning it to the pool. 

5361 # otherwise, the next caller to use this connection will 

5362 # read the response from _this_ request, not its own request. 

5363 # disconnecting discards the dirty state & forces the next 

5364 # caller to reconnect. 

5365 # NOTE: dicts have a consistent ordering; we're iterating 

5366 # through nodes.values() in the same order as we are when 

5367 # reading / writing to the connections above, which is critical 

5368 # for how we're using the nodes_written/nodes_read offsets. 

5369 for i, n in enumerate(nodes.values()): 

5370 if i < nodes_written and i >= nodes_read: 

5371 n.connection.disconnect() 

5372 n.connection_pool.release(n.connection) 

5373 

5374 # if the response isn't an exception it is a 

5375 # valid response from the node 

5376 # we're all done with that command, YAY! 

5377 # if we have more commands to attempt, we've run into problems. 

5378 # collect all the commands we are allowed to retry. 

5379 # (MOVED, ASK, or connection errors or timeout errors) 

5380 attempt = sorted( 

5381 ( 

5382 c 

5383 for c in attempt 

5384 if isinstance(c.result, ClusterPipeline.ERRORS_ALLOW_RETRY) 

5385 ), 

5386 key=lambda x: x.position, 

5387 ) 

5388 if attempt and allow_redirections: 

5389 # RETRY MAGIC HAPPENS HERE! 

5390 # send these remaining commands one at a time using `execute_command` 

5391 # in the main client. This keeps our retry logic 

5392 # in one place mostly, 

5393 # and allows us to be more confident in correctness of behavior. 

5394 # at this point any speed gains from pipelining have been lost 

5395 # anyway, so we might as well make the best 

5396 # attempt to get the correct behavior. 

5397 # 

5398 # The client command will handle retries for each 

5399 # individual command sequentially as we pass each 

5400 # one into `execute_command`. Any exceptions 

5401 # that bubble out should only appear once all 

5402 # retries have been exhausted. 

5403 # 

5404 # If a lot of commands have failed, we'll be setting the 

5405 # flag to rebuild the slots table from scratch. 

5406 # So MOVED errors should correct themselves fairly quickly. 

5407 self._pipe.reinitialize_counter += 1 

5408 if self._pipe._should_reinitialized(): 

5409 self._nodes_manager.initialize() 

5410 if is_default_node: 

5411 self._pipe.replace_default_node() 

5412 for c in attempt: 

5413 try: 

5414 # send each command individually like we 

5415 # do in the main client. 

5416 c.result = self._pipe.parent_execute_command(*c.args, **c.options) 

5417 except RedisError as e: 

5418 c.result = e 

5419 

5420 # turn the response back into a simple flat array that corresponds 

5421 # to the sequence of commands issued in the stack in pipeline.execute() 

5422 response = [] 

5423 for c in sorted(stack, key=lambda x: x.position): 

5424 if c.args[0] in self._pipe.cluster_response_callbacks: 

5425 # Remove keys entry, it needs only for cache. 

5426 c.options.pop("keys", None) 

5427 c.result = self._pipe._policies_callback_mapping[ 

5428 c.command_policies.response_policy 

5429 ]( 

5430 self._pipe.cluster_response_callbacks[c.args[0]]( 

5431 c.result, **c.options 

5432 ) 

5433 ) 

5434 response.append(c.result) 

5435 

5436 if raise_on_error: 

5437 self._raise_first_error(stack, start_time) 

5438 

5439 return response 

5440 

5441 def _is_nodes_flag(self, target_nodes): 

5442 return isinstance(target_nodes, str) and target_nodes in self._pipe.node_flags 

5443 

5444 def _parse_target_nodes(self, target_nodes): 

5445 if isinstance(target_nodes, list): 

5446 nodes = target_nodes 

5447 elif isinstance(target_nodes, ClusterNode): 

5448 # Supports passing a single ClusterNode as a variable 

5449 nodes = [target_nodes] 

5450 elif isinstance(target_nodes, dict): 

5451 # Supports dictionaries of the format {node_name: node}. 

5452 # It enables to execute commands with multi nodes as follows: 

5453 # rc.cluster_save_config(rc.get_primaries()) 

5454 nodes = target_nodes.values() 

5455 else: 

5456 raise TypeError( 

5457 "target_nodes type can be one of the following: " 

5458 "node_flag (PRIMARIES, REPLICAS, RANDOM, ALL_NODES)," 

5459 "ClusterNode, list<ClusterNode>, or dict<any, ClusterNode>. " 

5460 f"The passed type is {type(target_nodes)}" 

5461 ) 

5462 return nodes 

5463 

5464 def _determine_nodes( 

5465 self, *args, request_policy: Optional[RequestPolicy] = None, **kwargs 

5466 ) -> List["ClusterNode"]: 

5467 # Determine which nodes should be executed the command on. 

5468 # Returns a list of target nodes. 

5469 command = args[0].upper() 

5470 if ( 

5471 len(args) >= 2 

5472 and f"{args[0]} {args[1]}".upper() in self._pipe.command_flags 

5473 ): 

5474 command = f"{args[0]} {args[1]}".upper() 

5475 

5476 # The caller resolves the command's own policy - see 

5477 # ``RedisCluster._resolve_command_policies`` - so the only decision left here is 

5478 # an explicit nodes flag, which overrides it. 

5479 nodes_flag = kwargs.pop("nodes_flag", None) 

5480 if nodes_flag and self._is_nodes_flag(nodes_flag): 

5481 # nodes flag passed by the user 

5482 if nodes_flag in self._pipe._command_flags_mapping: 

5483 request_policy = self._pipe._command_flags_mapping[nodes_flag] 

5484 

5485 if request_policy is None: 

5486 raise RedisClusterException( 

5487 f"No targets were found to execute {args} command on" 

5488 ) 

5489 

5490 policy_callback = self._pipe._policies_callback_mapping[request_policy] 

5491 

5492 if request_policy == RequestPolicy.DEFAULT_KEYED: 

5493 nodes = policy_callback(command, *args) 

5494 elif request_policy == RequestPolicy.MULTI_SHARD: 

5495 nodes = policy_callback(*args, **kwargs) 

5496 elif request_policy == RequestPolicy.DEFAULT_KEYLESS: 

5497 nodes = policy_callback(args[0]) 

5498 else: 

5499 nodes = policy_callback() 

5500 

5501 if args[0].lower() == "ft.aggregate": 

5502 self._aggregate_nodes = nodes 

5503 

5504 return nodes 

5505 

5506 def multi(self): 

5507 raise RedisClusterException( 

5508 "method multi() is not supported outside of transactional context" 

5509 ) 

5510 

5511 def discard(self): 

5512 raise RedisClusterException( 

5513 "method discard() is not supported outside of transactional context" 

5514 ) 

5515 

5516 def watch(self, *names): 

5517 raise RedisClusterException( 

5518 "method watch() is not supported outside of transactional context" 

5519 ) 

5520 

5521 def unwatch(self, *names): 

5522 raise RedisClusterException( 

5523 "method unwatch() is not supported outside of transactional context" 

5524 ) 

5525 

5526 def delete(self, *names): 

5527 if len(names) != 1: 

5528 raise RedisClusterException( 

5529 "deleting multiple keys is not implemented in pipeline command" 

5530 ) 

5531 

5532 return self.execute_command("DEL", names[0]) 

5533 

5534 def unlink(self, *names): 

5535 if len(names) != 1: 

5536 raise RedisClusterException( 

5537 "unlinking multiple keys is not implemented in pipeline command" 

5538 ) 

5539 

5540 return self.execute_command("UNLINK", names[0]) 

5541 

5542 

5543class TransactionStrategy(AbstractStrategy): 

5544 NO_SLOTS_COMMANDS = {"UNWATCH"} 

5545 IMMEDIATE_EXECUTE_COMMANDS = {"WATCH", "UNWATCH"} 

5546 UNWATCH_COMMANDS = {"DISCARD", "EXEC", "UNWATCH"} 

5547 SLOT_REDIRECT_ERRORS = (AskError, MovedError) 

5548 CONNECTION_ERRORS = ( 

5549 ConnectionError, 

5550 OSError, 

5551 ClusterDownError, 

5552 SlotNotCoveredError, 

5553 ) 

5554 

5555 def __init__(self, pipe: ClusterPipeline): 

5556 super().__init__(pipe) 

5557 self._explicit_transaction = False 

5558 self._watching = False 

5559 self._pipeline_slots: Set[int] = set() 

5560 # True once a keyed (non-slot-agnostic) command has fixed the slot 

5561 self._transaction_has_keyed_slot = False 

5562 self._transaction_connection: Optional[Connection] = None 

5563 self._executing = False 

5564 self._retry = copy(self._pipe.retry) 

5565 self._retry.update_supported_errors( 

5566 RedisCluster.ERRORS_ALLOW_RETRY + self.SLOT_REDIRECT_ERRORS 

5567 ) 

5568 

5569 def _resolve_transaction_slot(self, *args) -> Optional[int]: 

5570 """ 

5571 Pick a slot for a transactional pipeline command. 

5572 

5573 Zero-key EVAL/EVALSHA can run on any primary. Reuse an existing 

5574 transaction slot when present so multiple zero-key scripts (or a 

5575 mix with keyed commands) stay single-slot. 

5576 """ 

5577 if args[0] in ClusterPipeline.NO_SLOTS_COMMANDS: 

5578 return None 

5579 

5580 if is_zero_key_eval_command(*args): 

5581 if self._pipeline_slots: 

5582 return next(iter(self._pipeline_slots)) 

5583 return self._pipe.determine_slot(*args) 

5584 

5585 slot_number = self._pipe.determine_slot(*args) 

5586 if ( 

5587 slot_number is not None 

5588 and self._pipeline_slots 

5589 and slot_number not in self._pipeline_slots 

5590 and not self._transaction_has_keyed_slot 

5591 ): 

5592 # Prior slots came only from zero-key scripts; retarget. 

5593 self._pipeline_slots.clear() 

5594 if slot_number is not None: 

5595 self._transaction_has_keyed_slot = True 

5596 return slot_number 

5597 

5598 def _get_client_and_connection_for_transaction(self) -> Tuple[Redis, Connection]: 

5599 """ 

5600 Find a connection for a pipeline transaction. 

5601 

5602 For running an atomic transaction, watch keys ensure that contents have not been 

5603 altered as long as the watch commands for those keys were sent over the same 

5604 connection. So once we start watching a key, we fetch a connection to the 

5605 node that owns that slot and reuse it. 

5606 """ 

5607 if not self._pipeline_slots: 

5608 raise RedisClusterException( 

5609 "At least a command with a key is needed to identify a node" 

5610 ) 

5611 

5612 node: ClusterNode = self._nodes_manager.get_node_from_slot( 

5613 list(self._pipeline_slots)[0], False 

5614 ) 

5615 redis_node: Redis = self._pipe.get_redis_connection(node) 

5616 if self._transaction_connection: 

5617 if not redis_node.connection_pool.owns_connection( 

5618 self._transaction_connection 

5619 ): 

5620 previous_node = self._nodes_manager.find_connection_owner( 

5621 self._transaction_connection 

5622 ) 

5623 previous_node.connection_pool.release(self._transaction_connection) 

5624 self._transaction_connection = None 

5625 

5626 if not self._transaction_connection: 

5627 self._transaction_connection = get_connection(redis_node) 

5628 

5629 return redis_node, self._transaction_connection 

5630 

5631 def execute_command(self, *args, **kwargs): 

5632 slot_number: Optional[int] = None 

5633 if args[0] not in ClusterPipeline.NO_SLOTS_COMMANDS: 

5634 slot_number = self._resolve_transaction_slot(*args) 

5635 

5636 if ( 

5637 self._watching or args[0] in self.IMMEDIATE_EXECUTE_COMMANDS 

5638 ) and not self._explicit_transaction: 

5639 if args[0] == "WATCH": 

5640 self._validate_watch() 

5641 

5642 if slot_number is not None: 

5643 if self._pipeline_slots and slot_number not in self._pipeline_slots: 

5644 raise CrossSlotTransactionError( 

5645 "Cannot watch or send commands on different slots" 

5646 ) 

5647 

5648 self._pipeline_slots.add(slot_number) 

5649 elif args[0] not in self.NO_SLOTS_COMMANDS: 

5650 raise RedisClusterException( 

5651 f"Cannot identify slot number for command: {args[0]}," 

5652 "it cannot be triggered in a transaction" 

5653 ) 

5654 

5655 return self._immediate_execute_command(*args, **kwargs) 

5656 else: 

5657 if slot_number is not None: 

5658 self._pipeline_slots.add(slot_number) 

5659 

5660 return self.pipeline_execute_command(*args, **kwargs) 

5661 

5662 def _validate_watch(self): 

5663 if self._explicit_transaction: 

5664 raise RedisError("Cannot issue a WATCH after a MULTI") 

5665 

5666 self._watching = True 

5667 

5668 def _immediate_execute_command(self, *args, **options): 

5669 return self._retry.call_with_retry( 

5670 lambda: self._get_connection_and_send_command(*args, **options), 

5671 self._reinitialize_on_error, 

5672 with_failure_count=True, 

5673 ) 

5674 

5675 def _get_connection_and_send_command(self, *args, **options): 

5676 redis_node, connection = self._get_client_and_connection_for_transaction() 

5677 

5678 # Start timing for observability 

5679 start_time = time.monotonic() 

5680 

5681 try: 

5682 response = self._send_command_parse_response( 

5683 connection, redis_node, args[0], *args, **options 

5684 ) 

5685 

5686 record_operation_duration( 

5687 command_name=args[0], 

5688 duration_seconds=time.monotonic() - start_time, 

5689 server_address=connection.host, 

5690 server_port=connection.port, 

5691 db_namespace=str(connection.db), 

5692 ) 

5693 

5694 return response 

5695 except Exception as e: 

5696 if connection: 

5697 # this is used to report the metrics based on host and port info 

5698 e.connection = connection 

5699 record_operation_duration( 

5700 command_name=args[0], 

5701 duration_seconds=time.monotonic() - start_time, 

5702 server_address=connection.host, 

5703 server_port=connection.port, 

5704 db_namespace=str(connection.db), 

5705 error=e, 

5706 ) 

5707 raise 

5708 

5709 def _send_command_parse_response( 

5710 self, conn, redis_node: Redis, command_name, *args, **options 

5711 ): 

5712 """ 

5713 Send a command and parse the response 

5714 """ 

5715 

5716 # HIMPORT SET's wire form depends on per-connection state: the fieldset 

5717 # must be PREPAREd on this connection first, and any fieldset discarded 

5718 # since this connection last reconciled must be dropped. The 

5719 # immediate/watched path (commands issued after WATCH, before MULTI) 

5720 # would otherwise send a bare HIMPORT SET and fail with "no such 

5721 # fieldset". Route it through the node's HIMPORT executor, the same way 

5722 # the normal cluster path, the batched MULTI/EXEC path, and standalone 

5723 # watched pipelines all do. 

5724 himport_set = parse_himport_set_args(args) 

5725 if himport_set is not None: 

5726 # HIMPORT SET in the joined or split raw form; operands at the right 

5727 # offsets. Too few operands returns None and falls through to the bare 

5728 # send so the server returns its arity error. 

5729 key, fieldset_name, values = himport_set 

5730 output = redis_node._himport_execute_set(conn, key, fieldset_name, values) 

5731 else: 

5732 conn.send_command(*args) 

5733 output = redis_node.parse_response(conn, command_name, **options) 

5734 

5735 if command_name in self.UNWATCH_COMMANDS: 

5736 self._watching = False 

5737 return output 

5738 

5739 def _reinitialize_on_error(self, error, failure_count): 

5740 if hasattr(error, "connection"): 

5741 record_error_count( 

5742 server_address=error.connection.host, 

5743 server_port=error.connection.port, 

5744 network_peer_address=error.connection.host, 

5745 network_peer_port=error.connection.port, 

5746 error_type=error, 

5747 retry_attempts=failure_count, 

5748 is_internal=True, 

5749 ) 

5750 

5751 if self._watching: 

5752 if type(error) in self.SLOT_REDIRECT_ERRORS and self._executing: 

5753 raise WatchError("Slot rebalancing occurred while watching keys") 

5754 

5755 if ( 

5756 type(error) in self.SLOT_REDIRECT_ERRORS 

5757 or type(error) in self.CONNECTION_ERRORS 

5758 ): 

5759 if self._transaction_connection: 

5760 if is_debug_log_enabled(): 

5761 logger.debug( 

5762 f"Operation failed, " 

5763 f"with connection: {self._transaction_connection}, " 

5764 f"details: {self._transaction_connection.extract_connection_details()}", 

5765 ) 

5766 # Disconnect and release back to pool 

5767 self._transaction_connection.disconnect() 

5768 node = self._nodes_manager.find_connection_owner( 

5769 self._transaction_connection 

5770 ) 

5771 if node and node.redis_connection: 

5772 node.redis_connection.connection_pool.release( 

5773 self._transaction_connection 

5774 ) 

5775 self._transaction_connection = None 

5776 

5777 self._pipe.reinitialize_counter += 1 

5778 if self._pipe._should_reinitialized(): 

5779 self._nodes_manager.initialize() 

5780 self.reinitialize_counter = 0 

5781 else: 

5782 if isinstance(error, AskError): 

5783 self._nodes_manager.move_slot(error) 

5784 

5785 self._executing = False 

5786 

5787 def _raise_first_error(self, responses, stack, start_time): 

5788 """ 

5789 Raise the first exception on the stack 

5790 """ 

5791 for r, cmd in zip(responses, stack): 

5792 if isinstance(r, Exception): 

5793 self.annotate_exception(r, cmd.position + 1, cmd.args) 

5794 

5795 record_operation_duration( 

5796 command_name="TRANSACTION", 

5797 duration_seconds=time.monotonic() - start_time, 

5798 server_address=self._transaction_connection.host, 

5799 server_port=self._transaction_connection.port, 

5800 db_namespace=str(self._transaction_connection.db), 

5801 ) 

5802 

5803 raise r 

5804 

5805 def execute(self, raise_on_error: bool = True) -> List[Any]: 

5806 stack = self._command_queue 

5807 if not stack and (not self._watching or not self._pipeline_slots): 

5808 return [] 

5809 

5810 return self._execute_transaction_with_retries(stack, raise_on_error) 

5811 

5812 def _execute_transaction_with_retries( 

5813 self, stack: List["PipelineCommand"], raise_on_error: bool 

5814 ): 

5815 return self._retry.call_with_retry( 

5816 lambda: self._execute_transaction(stack, raise_on_error), 

5817 lambda error, failure_count: self._reinitialize_on_error( 

5818 error, failure_count 

5819 ), 

5820 with_failure_count=True, 

5821 ) 

5822 

5823 def _execute_transaction( 

5824 self, stack: List["PipelineCommand"], raise_on_error: bool 

5825 ): 

5826 if len(self._pipeline_slots) > 1: 

5827 raise CrossSlotTransactionError( 

5828 "All keys involved in a cluster transaction must map to the same slot" 

5829 ) 

5830 

5831 self._executing = True 

5832 

5833 redis_node, connection = self._get_client_and_connection_for_transaction() 

5834 

5835 # Ensure fieldsets referenced by buffered HIMPORT SETs are prepared on this 

5836 # node's connection before the MULTI/EXEC block (session state, not 

5837 # transactional). All keys share one slot here, so it is a single node. 

5838 self._himport_prepare_pipeline( 

5839 redis_node, connection, [(c.args, c.options) for c in stack] 

5840 ) 

5841 

5842 stack = chain( 

5843 [PipelineCommand(("MULTI",))], 

5844 stack, 

5845 [PipelineCommand(("EXEC",))], 

5846 ) 

5847 commands = [c.args for c in stack if EMPTY_RESPONSE not in c.options] 

5848 packed_commands = connection.pack_commands(commands) 

5849 

5850 # Start timing for observability 

5851 start_time = time.monotonic() 

5852 

5853 connection.send_packed_command(packed_commands) 

5854 errors = [] 

5855 

5856 # parse off the response for MULTI 

5857 # NOTE: we need to handle ResponseErrors here and continue 

5858 # so that we read all the additional command messages from 

5859 # the socket 

5860 try: 

5861 redis_node.parse_response(connection, "MULTI") 

5862 except ResponseError as e: 

5863 self.annotate_exception(e, 0, "MULTI") 

5864 errors.append(e) 

5865 except self.CONNECTION_ERRORS as cluster_error: 

5866 self.annotate_exception(cluster_error, 0, "MULTI") 

5867 raise 

5868 

5869 # and all the other commands 

5870 for i, command in enumerate(self._command_queue): 

5871 if EMPTY_RESPONSE in command.options: 

5872 errors.append((i, command.options[EMPTY_RESPONSE])) 

5873 else: 

5874 try: 

5875 _ = redis_node.parse_response(connection, "_") 

5876 except self.SLOT_REDIRECT_ERRORS as slot_error: 

5877 self.annotate_exception(slot_error, i + 1, command.args) 

5878 errors.append(slot_error) 

5879 except self.CONNECTION_ERRORS as cluster_error: 

5880 self.annotate_exception(cluster_error, i + 1, command.args) 

5881 raise 

5882 except ResponseError as e: 

5883 self.annotate_exception(e, i + 1, command.args) 

5884 errors.append(e) 

5885 

5886 response = None 

5887 # parse the EXEC. 

5888 try: 

5889 response = redis_node.parse_response(connection, "EXEC") 

5890 except ExecAbortError: 

5891 if errors: 

5892 raise errors[0] 

5893 raise 

5894 

5895 self._executing = False 

5896 

5897 record_operation_duration( 

5898 command_name="TRANSACTION", 

5899 duration_seconds=time.monotonic() - start_time, 

5900 server_address=connection.host, 

5901 server_port=connection.port, 

5902 db_namespace=str(connection.db), 

5903 ) 

5904 

5905 # EXEC clears any watched keys 

5906 self._watching = False 

5907 

5908 if response is None: 

5909 raise WatchError("Watched variable changed.") 

5910 

5911 # put any parse errors into the response 

5912 for i, e in errors: 

5913 response.insert(i, e) 

5914 

5915 if len(response) != len(self._command_queue): 

5916 raise InvalidPipelineStack( 

5917 "Unexpected response length for cluster pipeline EXEC." 

5918 " Command stack was {} but response had length {}".format( 

5919 [c.args[0] for c in self._command_queue], len(response) 

5920 ) 

5921 ) 

5922 

5923 # find any errors in the response and raise if necessary 

5924 if raise_on_error or len(errors) > 0: 

5925 self._raise_first_error( 

5926 response, 

5927 self._command_queue, 

5928 start_time, 

5929 ) 

5930 

5931 # We have to run response callbacks manually 

5932 data = [] 

5933 for r, cmd in zip(response, self._command_queue): 

5934 if not isinstance(r, Exception): 

5935 command_name = cmd.args[0] 

5936 if command_name in self._pipe.cluster_response_callbacks: 

5937 r = self._pipe.cluster_response_callbacks[command_name]( 

5938 r, **cmd.options 

5939 ) 

5940 data.append(r) 

5941 return data 

5942 

5943 def reset(self): 

5944 self._command_queue = [] 

5945 

5946 # make sure to reset the connection state in the event that we were 

5947 # watching something 

5948 if self._transaction_connection: 

5949 try: 

5950 if self._watching: 

5951 # call this manually since our unwatch or 

5952 # immediate_execute_command methods can call reset() 

5953 self._transaction_connection.send_command("UNWATCH") 

5954 self._transaction_connection.read_response() 

5955 # we can safely return the connection to the pool here since we're 

5956 # sure we're no longer WATCHing anything 

5957 node = self._nodes_manager.find_connection_owner( 

5958 self._transaction_connection 

5959 ) 

5960 if node and node.redis_connection: 

5961 node.redis_connection.connection_pool.release( 

5962 self._transaction_connection 

5963 ) 

5964 self._transaction_connection = None 

5965 except self.CONNECTION_ERRORS: 

5966 # disconnect will also remove any previous WATCHes 

5967 if self._transaction_connection: 

5968 self._transaction_connection.disconnect() 

5969 node = self._nodes_manager.find_connection_owner( 

5970 self._transaction_connection 

5971 ) 

5972 if node and node.redis_connection: 

5973 node.redis_connection.connection_pool.release( 

5974 self._transaction_connection 

5975 ) 

5976 self._transaction_connection = None 

5977 

5978 # clean up the other instance attributes 

5979 self._watching = False 

5980 self._explicit_transaction = False 

5981 self._pipeline_slots = set() 

5982 self._transaction_has_keyed_slot = False 

5983 self._executing = False 

5984 

5985 def send_cluster_commands( 

5986 self, stack, raise_on_error=True, allow_redirections=True 

5987 ): 

5988 raise NotImplementedError( 

5989 "send_cluster_commands cannot be executed in transactional context." 

5990 ) 

5991 

5992 def multi(self): 

5993 if self._explicit_transaction: 

5994 raise RedisError("Cannot issue nested calls to MULTI") 

5995 if self._command_queue: 

5996 raise RedisError( 

5997 "Commands without an initial WATCH have already been issued" 

5998 ) 

5999 self._explicit_transaction = True 

6000 

6001 def watch(self, *names): 

6002 if self._explicit_transaction: 

6003 raise RedisError("Cannot issue a WATCH after a MULTI") 

6004 

6005 return self.execute_command("WATCH", *names) 

6006 

6007 def unwatch(self): 

6008 if self._watching: 

6009 return self.execute_command("UNWATCH") 

6010 

6011 return True 

6012 

6013 def discard(self): 

6014 self.reset() 

6015 

6016 def delete(self, *names): 

6017 return self.execute_command("DEL", *names) 

6018 

6019 def unlink(self, *names): 

6020 return self.execute_command("UNLINK", *names)