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

1877 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 copy import copy 

12from enum import Enum 

13from itertools import chain 

14from types import MethodType 

15from typing import ( 

16 TYPE_CHECKING, 

17 Any, 

18 Callable, 

19 Dict, 

20 Iterable, 

21 List, 

22 Literal, 

23 Optional, 

24 Set, 

25 Tuple, 

26 Type, 

27 Union, 

28) 

29 

30if TYPE_CHECKING: 

31 from redis.keyspace_notifications import ClusterKeyspaceNotifications 

32 

33from redis import _himport_exec 

34from redis._defaults import DEFAULT_RETRY_BASE, DEFAULT_RETRY_CAP, DEFAULT_RETRY_COUNT 

35from redis._parsers import CommandsParser, Encoder 

36from redis._parsers.commands import CommandPolicies, RequestPolicy, ResponsePolicy 

37from redis._parsers.helpers import parse_scan 

38from redis.backoff import ExponentialWithJitterBackoff, NoBackoff 

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

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

41from redis.commands import READ_COMMANDS, RedisClusterCommands 

42from redis.commands.helpers import list_or_args, parse_pubsub_subscriptions 

43from redis.commands.policies import PolicyResolver, StaticPolicyResolver 

44from redis.connection import ( 

45 Connection, 

46 ConnectionPool, 

47 parse_url, 

48) 

49from redis.crc import REDIS_CLUSTER_HASH_SLOTS, key_slot 

50from redis.event import ( 

51 AfterPooledConnectionsInstantiationEvent, 

52 AfterPubSubConnectionInstantiationEvent, 

53 AfterSlotsCacheRefreshEvent, 

54 ClientType, 

55 EventDispatcher, 

56 EventListenerInterface, 

57) 

58from redis.exceptions import ( 

59 AskError, 

60 AuthenticationError, 

61 ClusterDownError, 

62 ClusterError, 

63 ConnectionError, 

64 CrossSlotTransactionError, 

65 DataError, 

66 ExecAbortError, 

67 InvalidPipelineStack, 

68 MaxConnectionsError, 

69 MovedError, 

70 RedisClusterException, 

71 RedisError, 

72 ResponseError, 

73 SlotNotCoveredError, 

74 TimeoutError, 

75 TryAgainError, 

76 WatchError, 

77) 

78from redis.himport import HImportRegistry, parse_himport_set_args 

79from redis.lock import Lock 

80from redis.maint_notifications import ( 

81 MaintNotificationsConfig, 

82 OSSMaintNotificationsHandler, 

83) 

84from redis.observability.recorder import ( 

85 record_error_count, 

86 record_operation_duration, 

87) 

88from redis.retry import Retry 

89from redis.typing import ( 

90 ChannelT, 

91 FieldT, 

92 PubSubHandler, 

93 Subscription, 

94) 

95from redis.utils import ( 

96 check_protocol_version, 

97 deprecated_args, 

98 deprecated_function, 

99 dict_merge, 

100 experimental_method, 

101 list_keys_to_dict, 

102 merge_result, 

103 safe_str, 

104 str_if_bytes, 

105 truncate_text, 

106) 

107 

108logger = logging.getLogger(__name__) 

109 

110 

111def is_debug_log_enabled(): 

112 return logger.isEnabledFor(logging.DEBUG) 

113 

114 

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

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

117 

118 

119@deprecated_args( 

120 allowed_args=["redis_node"], 

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

122 version="5.3.0", 

123) 

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

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

126 

127 

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

129 cursors = {} 

130 ret = [] 

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

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

133 cursors[node_name] = cursor 

134 ret += r 

135 

136 return cursors, ret 

137 

138 

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

140 numsub_d = OrderedDict() 

141 for numsub_tups in res.values(): 

142 for channel, numsubbed in numsub_tups: 

143 try: 

144 numsub_d[channel] += numsubbed 

145 except KeyError: 

146 numsub_d[channel] = numsubbed 

147 

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

149 return ret_numsub 

150 

151 

152def parse_cluster_slots( 

153 resp: Any, **options: Any 

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

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

156 

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

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

159 

160 slots = {} 

161 for slot in resp: 

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

163 replicas = slot[3:] 

164 slots[start, end] = { 

165 "primary": fix_server(*primary), 

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

167 } 

168 

169 return slots 

170 

171 

172def parse_cluster_shards(resp, **options): 

173 """ 

174 Parse CLUSTER SHARDS response. 

175 """ 

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

177 return resp 

178 shards = [] 

179 for x in resp: 

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

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

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

183 nodes = x[3] 

184 for node in nodes: 

185 dict_node = {} 

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

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

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

189 shards.append(shard) 

190 

191 return shards 

192 

193 

194def parse_cluster_shards_with_str_keys(resp, **options): 

195 """ 

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

197 

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

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

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

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

202 """ 

203 if not resp: 

204 return resp 

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

206 return parse_cluster_shards(resp, **options) 

207 

208 shards = [] 

209 for shard_resp in resp: 

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

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

212 shard = { 

213 "slots": [ 

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

215 ], 

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

217 } 

218 shards.append(shard) 

219 return shards 

220 

221 

222def parse_cluster_shards_unified(resp, **options): 

223 """ 

224 Parse CLUSTER SHARDS into the approved unified shape. 

225 

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

227 RESP2 and RESP3 wire responses. 

228 """ 

229 if not resp: 

230 return resp 

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

232 shards = [] 

233 for shard_resp in resp: 

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

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

236 shard = { 

237 "slots": slots, 

238 "nodes": [ 

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

240 if isinstance(node, dict) 

241 else node 

242 for node in nodes 

243 ], 

244 } 

245 shards.append(shard) 

246 return shards 

247 

248 shards = [] 

249 for x in resp: 

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

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

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

253 nodes = x[3] 

254 for node in nodes: 

255 dict_node = {} 

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

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

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

259 shards.append(shard) 

260 return shards 

261 

262 

263def parse_cluster_myshardid(resp, **options): 

264 """ 

265 Parse CLUSTER MYSHARDID response. 

266 """ 

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

268 

269 

270PRIMARY = "primary" 

271REPLICA = "replica" 

272SLOT_ID = "slot-id" 

273 

274REDIS_ALLOWED_KEYS = ( 

275 "connection_class", 

276 "connection_pool", 

277 "connection_pool_class", 

278 "client_name", 

279 "credential_provider", 

280 "db", 

281 "decode_responses", 

282 "encoding", 

283 "encoding_errors", 

284 "host", 

285 "driver_info", 

286 "lib_name", 

287 "lib_version", 

288 "max_connections", 

289 "nodes_flag", 

290 "redis_connect_func", 

291 "password", 

292 "port", 

293 "timeout", 

294 "queue_class", 

295 "retry", 

296 "retry_on_timeout", 

297 "protocol", 

298 "legacy_responses", 

299 "socket_connect_timeout", 

300 "socket_keepalive", 

301 "socket_keepalive_options", 

302 "socket_read_size", 

303 "socket_timeout", 

304 "ssl", 

305 "ssl_ca_certs", 

306 "ssl_ca_data", 

307 "ssl_ca_path", 

308 "ssl_certfile", 

309 "ssl_cert_reqs", 

310 "ssl_include_verify_flags", 

311 "ssl_exclude_verify_flags", 

312 "ssl_keyfile", 

313 "ssl_password", 

314 "ssl_check_hostname", 

315 "unix_socket_path", 

316 "username", 

317 "cache", 

318 "cache_config", 

319 "maint_notifications_config", 

320) 

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

322 

323 

324def cleanup_kwargs(**kwargs): 

325 """ 

326 Remove unsupported or disabled keys from kwargs 

327 """ 

328 connection_kwargs = { 

329 k: v 

330 for k, v in kwargs.items() 

331 if k in REDIS_ALLOWED_KEYS and k not in KWARGS_DISABLED_KEYS 

332 } 

333 

334 return connection_kwargs 

335 

336 

337class MaintNotificationsAbstractRedisCluster: 

338 """ 

339 Abstract class for handling maintenance notifications logic. 

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

341 

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

343 

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

345 """ 

346 

347 def __init__( 

348 self, 

349 maint_notifications_config: Optional[MaintNotificationsConfig], 

350 **kwargs, 

351 ): 

352 # Initialize maintenance notifications. 

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

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

355 # the config it receives has already been validated. 

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

357 

358 if maint_notifications_config is None and is_protocol_supported: 

359 maint_notifications_config = MaintNotificationsConfig() 

360 

361 self.maint_notifications_config = maint_notifications_config 

362 

363 if self.maint_notifications_config and self.maint_notifications_config.enabled: 

364 self._oss_cluster_maint_notifications_handler = ( 

365 OSSMaintNotificationsHandler(self, self.maint_notifications_config) 

366 ) 

367 # Update connection kwargs for all future nodes connections 

368 self._update_connection_kwargs_for_maint_notifications( 

369 self._oss_cluster_maint_notifications_handler 

370 ) 

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

372 for node in self.get_nodes(): 

373 if node.redis_connection is None: 

374 continue 

375 node.redis_connection.connection_pool.update_maint_notifications_config( 

376 self.maint_notifications_config, 

377 oss_cluster_maint_notifications_handler=self._oss_cluster_maint_notifications_handler, 

378 ) 

379 else: 

380 self._oss_cluster_maint_notifications_handler = None 

381 

382 def _update_connection_kwargs_for_maint_notifications( 

383 self, oss_cluster_maint_notifications_handler: OSSMaintNotificationsHandler 

384 ): 

385 """ 

386 Update the connection kwargs for all future connections. 

387 """ 

388 self.nodes_manager.connection_kwargs.update( 

389 { 

390 "oss_cluster_maint_notifications_handler": oss_cluster_maint_notifications_handler, 

391 } 

392 ) 

393 

394 

395class AbstractRedisCluster: 

396 RedisClusterRequestTTL = 16 

397 

398 PRIMARIES = "primaries" 

399 REPLICAS = "replicas" 

400 ALL_NODES = "all" 

401 RANDOM = "random" 

402 DEFAULT_NODE = "default-node" 

403 

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

405 

406 COMMAND_FLAGS = dict_merge( 

407 list_keys_to_dict( 

408 [ 

409 "ACL CAT", 

410 "ACL DELUSER", 

411 "ACL DRYRUN", 

412 "ACL GENPASS", 

413 "ACL GETUSER", 

414 "ACL HELP", 

415 "ACL LIST", 

416 "ACL LOG", 

417 "ACL LOAD", 

418 "ACL SAVE", 

419 "ACL SETUSER", 

420 "ACL USERS", 

421 "ACL WHOAMI", 

422 "AUTH", 

423 "CLIENT LIST", 

424 "CLIENT SETINFO", 

425 "CLIENT SETNAME", 

426 "CLIENT GETNAME", 

427 "CONFIG SET", 

428 "CONFIG REWRITE", 

429 "CONFIG RESETSTAT", 

430 "TIME", 

431 "PUBSUB CHANNELS", 

432 "PUBSUB NUMPAT", 

433 "PUBSUB NUMSUB", 

434 "PUBSUB SHARDCHANNELS", 

435 "PUBSUB SHARDNUMSUB", 

436 "PING", 

437 "INFO", 

438 "SHUTDOWN", 

439 "KEYS", 

440 "DBSIZE", 

441 "BGSAVE", 

442 "SLOWLOG GET", 

443 "SLOWLOG LEN", 

444 "SLOWLOG RESET", 

445 "WAIT", 

446 "WAITAOF", 

447 "SAVE", 

448 "MEMORY PURGE", 

449 "MEMORY MALLOC-STATS", 

450 "MEMORY STATS", 

451 "LASTSAVE", 

452 "CLIENT TRACKINGINFO", 

453 "CLIENT PAUSE", 

454 "CLIENT UNPAUSE", 

455 "CLIENT UNBLOCK", 

456 "CLIENT ID", 

457 "CLIENT REPLY", 

458 "CLIENT GETREDIR", 

459 "CLIENT INFO", 

460 "CLIENT KILL", 

461 "READONLY", 

462 "CLUSTER INFO", 

463 "CLUSTER MEET", 

464 "CLUSTER MYSHARDID", 

465 "CLUSTER NODES", 

466 "CLUSTER REPLICAS", 

467 "CLUSTER RESET", 

468 "CLUSTER SET-CONFIG-EPOCH", 

469 "CLUSTER SLOTS", 

470 "CLUSTER SHARDS", 

471 "CLUSTER COUNT-FAILURE-REPORTS", 

472 "CLUSTER KEYSLOT", 

473 "COMMAND", 

474 "COMMAND COUNT", 

475 "COMMAND LIST", 

476 "COMMAND GETKEYS", 

477 "CONFIG GET", 

478 "DEBUG", 

479 "RANDOMKEY", 

480 "READONLY", 

481 "READWRITE", 

482 "TIME", 

483 "TFUNCTION LOAD", 

484 "TFUNCTION DELETE", 

485 "TFUNCTION LIST", 

486 "TFCALL", 

487 "TFCALLASYNC", 

488 "LATENCY HISTORY", 

489 "LATENCY LATEST", 

490 "LATENCY RESET", 

491 "MODULE LIST", 

492 "MODULE LOAD", 

493 "MODULE UNLOAD", 

494 "MODULE LOADEX", 

495 ], 

496 DEFAULT_NODE, 

497 ), 

498 list_keys_to_dict( 

499 [ 

500 "FLUSHALL", 

501 "FLUSHDB", 

502 "FUNCTION DELETE", 

503 "FUNCTION FLUSH", 

504 "FUNCTION LIST", 

505 "FUNCTION LOAD", 

506 "FUNCTION RESTORE", 

507 "SCAN", 

508 "SCRIPT EXISTS", 

509 "SCRIPT FLUSH", 

510 "SCRIPT LOAD", 

511 ], 

512 PRIMARIES, 

513 ), 

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

515 list_keys_to_dict( 

516 [ 

517 "CLUSTER COUNTKEYSINSLOT", 

518 "CLUSTER DELSLOTS", 

519 "CLUSTER DELSLOTSRANGE", 

520 "CLUSTER GETKEYSINSLOT", 

521 "CLUSTER SETSLOT", 

522 ], 

523 SLOT_ID, 

524 ), 

525 ) 

526 

527 SEARCH_COMMANDS = ( 

528 [ 

529 "FT.CREATE", 

530 "FT.SEARCH", 

531 "FT.AGGREGATE", 

532 "FT.EXPLAIN", 

533 "FT.EXPLAINCLI", 

534 "FT,PROFILE", 

535 "FT.ALTER", 

536 "FT.DROPINDEX", 

537 "FT.ALIASADD", 

538 "FT.ALIASUPDATE", 

539 "FT.ALIASDEL", 

540 "FT.ALIASLIST", 

541 "FT.TAGVALS", 

542 "FT.SUGADD", 

543 "FT.SUGGET", 

544 "FT.SUGDEL", 

545 "FT.SUGLEN", 

546 "FT.SYNUPDATE", 

547 "FT.SYNDUMP", 

548 "FT.SPELLCHECK", 

549 "FT.DICTADD", 

550 "FT.DICTDEL", 

551 "FT.DICTDUMP", 

552 "FT.INFO", 

553 "FT._LIST", 

554 "FT.CONFIG", 

555 "FT.ADD", 

556 "FT.DEL", 

557 "FT.DROP", 

558 "FT.GET", 

559 "FT.MGET", 

560 "FT.SYNADD", 

561 ], 

562 ) 

563 

564 CLUSTER_COMMANDS_RESPONSE_CALLBACKS = { 

565 "CLUSTER SLOTS": parse_cluster_slots, 

566 "CLUSTER SHARDS": parse_cluster_shards, 

567 "CLUSTER MYSHARDID": parse_cluster_myshardid, 

568 } 

569 

570 RESULT_CALLBACKS = dict_merge( 

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

572 list_keys_to_dict( 

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

574 ), 

575 list_keys_to_dict( 

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

577 ), 

578 list_keys_to_dict( 

579 [ 

580 "PING", 

581 "CONFIG SET", 

582 "CONFIG REWRITE", 

583 "CONFIG RESETSTAT", 

584 "CLIENT SETNAME", 

585 "BGSAVE", 

586 "SLOWLOG RESET", 

587 "SAVE", 

588 "MEMORY PURGE", 

589 "CLIENT PAUSE", 

590 "CLIENT UNPAUSE", 

591 ], 

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

593 ), 

594 list_keys_to_dict( 

595 ["DBSIZE", "WAIT"], 

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

597 ), 

598 list_keys_to_dict( 

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

600 ), 

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

602 list_keys_to_dict( 

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

604 ), 

605 list_keys_to_dict( 

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

607 ), 

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

609 ) 

610 

611 ERRORS_ALLOW_RETRY = ( 

612 ConnectionError, 

613 TimeoutError, 

614 ClusterDownError, 

615 SlotNotCoveredError, 

616 ) 

617 

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

619 """Replace the default cluster node. 

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

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

622 nodes in the cluster. 

623 

624 Args: 

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

626 node. Defaults to None. 

627 """ 

628 if target_node: 

629 self.nodes_manager.default_node = target_node 

630 else: 

631 curr_node = self.get_default_node() 

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

633 if primaries: 

634 # Choose a primary if the cluster contains different primaries 

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

636 else: 

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

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

639 if replicas: 

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

641 

642 

643class RedisCluster( 

644 AbstractRedisCluster, MaintNotificationsAbstractRedisCluster, RedisClusterCommands 

645): 

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

647 _is_async_client: Literal[False] = False 

648 

649 @classmethod 

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

651 """ 

652 Return a Redis client object configured from the given URL 

653 

654 For example:: 

655 

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

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

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

659 

660 Three URL schemes are supported: 

661 

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

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

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

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

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

667 

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

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

670 with their corresponding characters. Querystring values are decoded 

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

672 

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

674 found will be used: 

675 

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

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

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

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

680 

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

682 

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

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

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

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

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

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

689 arguments always win. 

690 

691 """ 

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

693 

694 @deprecated_args( 

695 args_to_warn=["read_from_replicas"], 

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

697 version="5.3.0", 

698 ) 

699 @deprecated_args( 

700 args_to_warn=[ 

701 "cluster_error_retry_attempts", 

702 ], 

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

704 version="6.0.0", 

705 ) 

706 def __init__( 

707 self, 

708 host: Optional[str] = None, 

709 port: int = 6379, 

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

711 cluster_error_retry_attempts: int = DEFAULT_RETRY_COUNT, 

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

713 require_full_coverage: bool = True, 

714 reinitialize_steps: int = 5, 

715 read_from_replicas: bool = False, 

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

717 dynamic_startup_nodes: bool = True, 

718 url: Optional[str] = None, 

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

720 cache: Optional[CacheInterface] = None, 

721 cache_config: Optional[CacheConfig] = None, 

722 event_dispatcher: Optional[EventDispatcher] = None, 

723 policy_resolver: PolicyResolver = StaticPolicyResolver(), 

724 maint_notifications_config: Optional[MaintNotificationsConfig] = None, 

725 **kwargs, 

726 ): 

727 """ 

728 Initialize a new RedisCluster client. 

729 

730 :param startup_nodes: 

731 List of nodes from which initial bootstrapping can be done 

732 :param host: 

733 Can be used to point to a startup node 

734 :param port: 

735 Can be used to point to a startup node 

736 :param require_full_coverage: 

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

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

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

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

741 commands. See - 

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

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

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

745 will be thrown. 

746 :param read_from_replicas: 

747 @deprecated - please use load_balancing_strategy instead 

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

749 stale data. 

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

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

752 :param load_balancing_strategy: 

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

754 strategy that will be used for cluster node selection. 

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

756 :param dynamic_startup_nodes: 

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

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

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

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

761 listed in the CLUSTER SLOTS output. 

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

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

764 :param cluster_error_retry_attempts: 

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

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

767 

768 Number of times to retry before raising an error when 

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

770 :class:`~.ClusterDownError` are encountered 

771 :param retry: 

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

773 retries for the cluster client. 

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

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

776 the number of retries for the cluster client. 

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

778 :param reinitialize_steps: 

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

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

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

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

783 redirected node. 

784 To reinitialize the cluster on every MOVED error, set 

785 reinitialize_steps to 1. 

786 To avoid reinitializing the cluster on moved errors, set 

787 reinitialize_steps to 0. 

788 :param address_remap: 

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

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

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

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

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

794 

795 :param maint_notifications_config: 

796 Configures the nodes connections to support maintenance notifications - see 

797 `redis.maint_notifications.MaintNotificationsConfig` for details. 

798 Only supported with RESP3. 

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

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

801 initialization). 

802 :**kwargs: 

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

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

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

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

807 Some kwargs are not supported and will raise a 

808 RedisClusterException: 

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

810 

811 """ 

812 if startup_nodes is None: 

813 startup_nodes = [] 

814 

815 if "db" in kwargs: 

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

817 raise RedisClusterException( 

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

819 ) 

820 

821 if "retry" in kwargs: 

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

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

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

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

826 raise RedisClusterException( 

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

828 ) 

829 

830 # Get the startup node/s 

831 from_url = False 

832 if url is not None: 

833 from_url = True 

834 url_options = parse_url(url) 

835 if "path" in url_options: 

836 raise RedisClusterException( 

837 "RedisCluster does not currently support Unix Domain " 

838 "Socket connections" 

839 ) 

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

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

842 raise RedisClusterException( 

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

844 ) 

845 kwargs.update(url_options) 

846 host = kwargs.get("host") 

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

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

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

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

851 elif len(startup_nodes) == 0: 

852 # No startup node was provided 

853 raise RedisClusterException( 

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

855 "cluster. Please provide one of the followings:\n" 

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

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

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

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

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

861 ) 

862 # Update the connection arguments 

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

864 # method should be run 

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

866 # inside the RedisCluster.on_connect() function 

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

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

869 kwargs = cleanup_kwargs(**kwargs) 

870 if retry: 

871 self.retry = retry 

872 else: 

873 self.retry = Retry( 

874 backoff=ExponentialWithJitterBackoff( 

875 base=DEFAULT_RETRY_BASE, cap=DEFAULT_RETRY_CAP 

876 ), 

877 retries=cluster_error_retry_attempts, 

878 ) 

879 

880 self.encoder = Encoder( 

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

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

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

884 ) 

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

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

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

888 

889 if ( 

890 maint_notifications_config 

891 and maint_notifications_config.enabled 

892 and not check_protocol_version(protocol, 3) 

893 ): 

894 raise RedisError( 

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

896 ) 

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

898 maint_notifications_config = MaintNotificationsConfig() 

899 

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

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

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

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

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

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

906 self._himport_registry = HImportRegistry() 

907 

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

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

910 self.read_from_replicas = read_from_replicas 

911 self.load_balancing_strategy = load_balancing_strategy 

912 self.reinitialize_counter = 0 

913 self.reinitialize_steps = reinitialize_steps 

914 if event_dispatcher is None: 

915 self._event_dispatcher = EventDispatcher() 

916 else: 

917 self._event_dispatcher = event_dispatcher 

918 self.startup_nodes = startup_nodes 

919 

920 self.nodes_manager = NodesManager( 

921 startup_nodes=startup_nodes, 

922 from_url=from_url, 

923 require_full_coverage=require_full_coverage, 

924 dynamic_startup_nodes=dynamic_startup_nodes, 

925 address_remap=address_remap, 

926 cache=cache, 

927 cache_config=cache_config, 

928 event_dispatcher=self._event_dispatcher, 

929 maint_notifications_config=maint_notifications_config, 

930 himport_registry=self._himport_registry, 

931 **kwargs, 

932 ) 

933 

934 cluster_response_callbacks = dict( 

935 self.__class__.CLUSTER_COMMANDS_RESPONSE_CALLBACKS 

936 ) 

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

938 protocol = kwargs.get("protocol") 

939 if not legacy_responses: 

940 cluster_response_callbacks["CLUSTER SHARDS"] = parse_cluster_shards_unified 

941 elif protocol is None: 

942 cluster_response_callbacks["CLUSTER SHARDS"] = ( 

943 parse_cluster_shards_with_str_keys 

944 ) 

945 self.cluster_response_callbacks = CaseInsensitiveDict( 

946 cluster_response_callbacks 

947 ) 

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

949 

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

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

952 self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS, 

953 self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS, 

954 self.__class__.ALL_NODES: RequestPolicy.ALL_NODES, 

955 self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS, 

956 self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE, 

957 SLOT_ID: RequestPolicy.DEFAULT_KEYED, 

958 } 

959 

960 self._policies_callback_mapping: dict[ 

961 Union[RequestPolicy, ResponsePolicy], Callable 

962 ] = { 

963 RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [ 

964 self.get_random_primary_or_all_nodes(command_name) 

965 ], 

966 RequestPolicy.DEFAULT_KEYED: lambda command, 

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

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

969 RequestPolicy.ALL_SHARDS: self.get_primaries, 

970 RequestPolicy.ALL_NODES: self.get_nodes, 

971 RequestPolicy.ALL_REPLICAS: self.get_replicas, 

972 RequestPolicy.MULTI_SHARD: lambda *args, 

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

974 RequestPolicy.SPECIAL: self.get_special_nodes, 

975 ResponsePolicy.DEFAULT_KEYLESS: lambda res: res, 

976 ResponsePolicy.DEFAULT_KEYED: lambda res: res, 

977 } 

978 

979 self._policy_resolver = policy_resolver 

980 self.commands_parser = CommandsParser(self) 

981 

982 # Node where FT.AGGREGATE command is executed. 

983 self._aggregate_nodes = None 

984 self._lock = threading.RLock() 

985 

986 MaintNotificationsAbstractRedisCluster.__init__( 

987 self, maint_notifications_config, **kwargs 

988 ) 

989 

990 def __enter__(self): 

991 return self 

992 

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

994 self.close() 

995 

996 def __del__(self): 

997 try: 

998 self.close() 

999 except Exception: 

1000 pass 

1001 

1002 def disconnect_connection_pools(self): 

1003 for node in self.get_nodes(): 

1004 if node.redis_connection: 

1005 try: 

1006 node.redis_connection.connection_pool.disconnect() 

1007 except OSError: 

1008 # Client was already disconnected. do nothing 

1009 pass 

1010 

1011 def on_connect(self, connection): 

1012 """ 

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

1014 READONLY if it is set during object initialization. 

1015 """ 

1016 connection.on_connect() 

1017 

1018 if self.read_from_replicas or self.load_balancing_strategy: 

1019 # Sending READONLY command to server to configure connection as 

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

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

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

1023 # READONLY would not affect executing write commands. 

1024 connection.send_command("READONLY") 

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

1026 raise ConnectionError("READONLY command failed") 

1027 

1028 if self.user_on_connect_func is not None: 

1029 self.user_on_connect_func(connection) 

1030 

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

1032 if not node.redis_connection: 

1033 with self._lock: 

1034 if not node.redis_connection: 

1035 self.nodes_manager.create_redis_connections([node]) 

1036 return node.redis_connection 

1037 

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

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

1040 

1041 def get_primaries(self): 

1042 return self.nodes_manager.get_nodes_by_server_type(PRIMARY) 

1043 

1044 def get_replicas(self): 

1045 return self.nodes_manager.get_nodes_by_server_type(REPLICA) 

1046 

1047 def get_random_node(self): 

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

1049 

1050 def get_random_primary_or_all_nodes(self, command_name): 

1051 """ 

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

1053 """ 

1054 if self.read_from_replicas and command_name in READ_COMMANDS: 

1055 return self.get_random_node() 

1056 

1057 return self.get_random_primary_node() 

1058 

1059 def get_nodes(self): 

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

1061 

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

1063 """ 

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

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

1066 returned. 

1067 """ 

1068 slot = self.keyslot(key) 

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

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

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

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

1073 return None 

1074 elif replica: 

1075 node_idx = 1 

1076 else: 

1077 # primary 

1078 node_idx = 0 

1079 

1080 return slot_cache[node_idx] 

1081 

1082 def get_default_node(self): 

1083 """ 

1084 Get the cluster's default node 

1085 """ 

1086 return self.nodes_manager.default_node 

1087 

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

1089 """ 

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

1091 """ 

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

1093 slot = self.determine_slot(*args) 

1094 node = self.nodes_manager.get_node_from_slot( 

1095 slot, 

1096 self.read_from_replicas and command in READ_COMMANDS, 

1097 self.load_balancing_strategy if command in READ_COMMANDS else None, 

1098 ) 

1099 return [node] 

1100 

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

1102 """ 

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

1104 """ 

1105 keys = self._get_command_keys(*args) 

1106 commands = [] 

1107 

1108 for key in keys: 

1109 commands.append( 

1110 { 

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

1112 "kwargs": kwargs, 

1113 } 

1114 ) 

1115 

1116 return commands 

1117 

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

1119 """ 

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

1121 """ 

1122 if not self._aggregate_nodes: 

1123 raise RedisClusterException( 

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

1125 ) 

1126 

1127 return self._aggregate_nodes 

1128 

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

1130 """ 

1131 Returns a random primary node 

1132 """ 

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

1134 

1135 def _evaluate_all_succeeded(self, res): 

1136 """ 

1137 Evaluate the result of a command with ResponsePolicy.ALL_SUCCEEDED 

1138 """ 

1139 first_successful_response = None 

1140 

1141 if isinstance(res, dict): 

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

1143 if value: 

1144 if first_successful_response is None: 

1145 first_successful_response = {key: value} 

1146 else: 

1147 return {key: False} 

1148 else: 

1149 for response in res: 

1150 if response: 

1151 if first_successful_response is None: 

1152 # Dynamically resolve type 

1153 first_successful_response = type(response)(response) 

1154 else: 

1155 return type(response)(False) 

1156 

1157 return first_successful_response 

1158 

1159 def set_default_node(self, node): 

1160 """ 

1161 Set the default node of the cluster. 

1162 :param node: 'ClusterNode' 

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

1164 """ 

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

1166 return False 

1167 self.nodes_manager.default_node = node 

1168 return True 

1169 

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

1171 self.retry = retry 

1172 

1173 def monitor(self, target_node=None): 

1174 """ 

1175 Returns a Monitor object for the specified target node. 

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

1177 specified. 

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

1179 next_command() method returns one command from monitor 

1180 listen() method yields commands from monitor. 

1181 """ 

1182 if target_node is None: 

1183 target_node = self.get_default_node() 

1184 if target_node.redis_connection is None: 

1185 raise RedisClusterException( 

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

1187 ) 

1188 return target_node.redis_connection.monitor() 

1189 

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

1191 """ 

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

1193 connected to the specified node 

1194 """ 

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

1196 

1197 def keyspace_notifications( 

1198 self, 

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

1200 ignore_subscribe_messages: bool = True, 

1201 ) -> "ClusterKeyspaceNotifications": 

1202 """ 

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

1204 object for subscribing to keyspace and keyevent notifications across 

1205 all primary nodes in the cluster. 

1206 

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

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

1209 

1210 Args: 

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

1212 notifications. 

1213 ignore_subscribe_messages: If True, subscribe/unsubscribe 

1214 confirmations are not returned by 

1215 get_message/listen. 

1216 """ 

1217 from redis.keyspace_notifications import ClusterKeyspaceNotifications 

1218 

1219 return ClusterKeyspaceNotifications( 

1220 self, 

1221 key_prefix=key_prefix, 

1222 ignore_subscribe_messages=ignore_subscribe_messages, 

1223 ) 

1224 

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

1226 """ 

1227 Cluster impl: 

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

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

1230 that simulating pipelines will work correctly. Each 

1231 command will be called directly when used and 

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

1233 """ 

1234 if shard_hint: 

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

1236 

1237 return ClusterPipeline( 

1238 nodes_manager=self.nodes_manager, 

1239 commands_parser=self.commands_parser, 

1240 startup_nodes=self.nodes_manager.startup_nodes, 

1241 result_callbacks=self.result_callbacks, 

1242 cluster_response_callbacks=self.cluster_response_callbacks, 

1243 read_from_replicas=self.read_from_replicas, 

1244 load_balancing_strategy=self.load_balancing_strategy, 

1245 reinitialize_steps=self.reinitialize_steps, 

1246 retry=self.retry, 

1247 lock=self._lock, 

1248 transaction=transaction, 

1249 event_dispatcher=self._event_dispatcher, 

1250 ) 

1251 

1252 def lock( 

1253 self, 

1254 name, 

1255 timeout=None, 

1256 sleep=0.1, 

1257 blocking=True, 

1258 blocking_timeout=None, 

1259 lock_class=None, 

1260 thread_local=True, 

1261 raise_on_release_error: bool = True, 

1262 ): 

1263 """ 

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

1265 the behavior of threading.Lock. 

1266 

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

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

1269 

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

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

1272 holding the lock. 

1273 

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

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

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

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

1278 argument to ``acquire``. 

1279 

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

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

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

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

1284 

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

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

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

1288 you have created your own custom lock class. 

1289 

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

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

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

1293 another thread. Consider the following timeline: 

1294 

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

1296 thread-1 sets the token to "abc" 

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

1298 Lock instance. 

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

1300 key. 

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

1302 thread-2 sets the token to "xyz" 

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

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

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

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

1307 

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

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

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

1311 will be logged and the exception will be suppressed. 

1312 

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

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

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

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

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

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

1319 thread local storage.""" 

1320 if lock_class is None: 

1321 lock_class = Lock 

1322 return lock_class( 

1323 self, 

1324 name, 

1325 timeout=timeout, 

1326 sleep=sleep, 

1327 blocking=blocking, 

1328 blocking_timeout=blocking_timeout, 

1329 thread_local=thread_local, 

1330 raise_on_release_error=raise_on_release_error, 

1331 ) 

1332 

1333 def set_response_callback(self, command, callback): 

1334 """Set a custom Response Callback""" 

1335 self.cluster_response_callbacks[command] = callback 

1336 

1337 def _determine_nodes( 

1338 self, *args, request_policy: RequestPolicy, **kwargs 

1339 ) -> List["ClusterNode"]: 

1340 """ 

1341 Determines a nodes the command should be executed on. 

1342 """ 

1343 command = args[0].upper() 

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

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

1346 

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

1348 if nodes_flag is not None: 

1349 # nodes flag passed by the user 

1350 command_flag = nodes_flag 

1351 else: 

1352 # get the nodes group for this command if it was predefined 

1353 command_flag = self.command_flags.get(command) 

1354 

1355 if command_flag in self._command_flags_mapping: 

1356 request_policy = self._command_flags_mapping[command_flag] 

1357 

1358 policy_callback = self._policies_callback_mapping[request_policy] 

1359 

1360 if request_policy == RequestPolicy.DEFAULT_KEYED: 

1361 nodes = policy_callback(command, *args) 

1362 elif request_policy == RequestPolicy.MULTI_SHARD: 

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

1364 elif request_policy == RequestPolicy.DEFAULT_KEYLESS: 

1365 nodes = policy_callback(args[0]) 

1366 else: 

1367 nodes = policy_callback() 

1368 

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

1370 self._aggregate_nodes = nodes 

1371 

1372 return nodes 

1373 

1374 def _should_reinitialized(self): 

1375 # To reinitialize the cluster on every MOVED error, 

1376 # set reinitialize_steps to 1. 

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

1378 # reinitialize_steps to 0. 

1379 if self.reinitialize_steps == 0: 

1380 return False 

1381 else: 

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

1383 

1384 def keyslot(self, key): 

1385 """ 

1386 Calculate keyslot for a given key. 

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

1388 """ 

1389 k = self.encoder.encode(key) 

1390 return key_slot(k) 

1391 

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

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

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

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

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

1397 

1398 @property 

1399 def himport_registry(self) -> HImportRegistry: 

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

1401 

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

1403 """ 

1404 return self._himport_registry 

1405 

1406 @experimental_method() 

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

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

1409 self._himport_registry.prepare(fieldset_name, fields) 

1410 return True 

1411 

1412 @experimental_method() 

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

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

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

1416 

1417 @experimental_method() 

1418 def himport_discard_all(self) -> int: 

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

1420 return self._himport_registry.discard_all() 

1421 

1422 def _get_command_keys(self, *args): 

1423 """ 

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

1425 returned. 

1426 

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

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

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

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

1431 

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

1433 """ 

1434 redis_conn = self.get_default_node().redis_connection 

1435 return self.commands_parser.get_keys(redis_conn, *args) 

1436 

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

1438 """ 

1439 Figure out what slot to use based on args. 

1440 

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

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

1443 all map to the same key slot. 

1444 """ 

1445 command = args[0] 

1446 if self.command_flags.get(command) == SLOT_ID: 

1447 # The command contains the slot ID 

1448 return args[1] 

1449 

1450 # Get the keys in the command 

1451 

1452 # CLIENT TRACKING is a special case. 

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

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

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

1456 return None 

1457 

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

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

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

1461 # EVAL/EVALSHA. 

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

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

1464 if len(args) <= 2: 

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

1466 num_actual_keys = int(args[2]) 

1467 eval_keys = args[3 : 3 + num_actual_keys] 

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

1469 # so we can just return a random slot 

1470 if len(eval_keys) == 0: 

1471 return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS) 

1472 keys = eval_keys 

1473 else: 

1474 keys = self._get_command_keys(*args) 

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

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

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

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

1479 return random.randrange(0, REDIS_CLUSTER_HASH_SLOTS) 

1480 raise RedisClusterException( 

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

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

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

1484 ) 

1485 

1486 # single key command 

1487 if len(keys) == 1: 

1488 return self.keyslot(keys[0]) 

1489 

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

1491 # the same slot 

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

1493 if len(slots) != 1: 

1494 raise RedisClusterException( 

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

1496 ) 

1497 

1498 return slots.pop() 

1499 

1500 def get_encoder(self): 

1501 """ 

1502 Get the connections' encoder 

1503 """ 

1504 return self.encoder 

1505 

1506 def get_connection_kwargs(self): 

1507 """ 

1508 Get the connections' key-word arguments 

1509 """ 

1510 return self.nodes_manager.connection_kwargs 

1511 

1512 def _is_nodes_flag(self, target_nodes): 

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

1514 

1515 def _parse_target_nodes(self, target_nodes): 

1516 if isinstance(target_nodes, list): 

1517 nodes = target_nodes 

1518 elif isinstance(target_nodes, ClusterNode): 

1519 # Supports passing a single ClusterNode as a variable 

1520 nodes = [target_nodes] 

1521 elif isinstance(target_nodes, dict): 

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

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

1524 # rc.cluster_save_config(rc.get_primaries()) 

1525 nodes = target_nodes.values() 

1526 else: 

1527 raise TypeError( 

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

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

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

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

1532 ) 

1533 return nodes 

1534 

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

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

1537 

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

1539 """ 

1540 Wrapper for ERRORS_ALLOW_RETRY error handling. 

1541 

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

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

1544 configured. 

1545 

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

1547 

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

1549 nodes_flag: PRIMARIES, REPLICAS, ALL_NODES, RANDOM 

1550 ClusterNode 

1551 list<ClusterNode> 

1552 dict<Any, ClusterNode> 

1553 """ 

1554 target_nodes_specified = False 

1555 is_default_node = False 

1556 target_nodes = None 

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

1558 command_policies = self._policy_resolver.resolve(args[0].lower()) 

1559 

1560 if passed_targets is not None and not self._is_nodes_flag(passed_targets): 

1561 target_nodes = self._parse_target_nodes(passed_targets) 

1562 target_nodes_specified = True 

1563 

1564 if not command_policies and not target_nodes_specified: 

1565 command = args[0].upper() 

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

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

1568 

1569 # We only could resolve key properties if command is not 

1570 # in a list of pre-defined request policies 

1571 command_flag = self.command_flags.get(command) 

1572 if not command_flag: 

1573 # Fallback to default policy 

1574 if not self.get_default_node(): 

1575 slot = None 

1576 else: 

1577 slot = self.determine_slot(*args) 

1578 if slot is None: 

1579 command_policies = CommandPolicies() 

1580 else: 

1581 command_policies = CommandPolicies( 

1582 request_policy=RequestPolicy.DEFAULT_KEYED, 

1583 response_policy=ResponsePolicy.DEFAULT_KEYED, 

1584 ) 

1585 else: 

1586 if command_flag in self._command_flags_mapping: 

1587 command_policies = CommandPolicies( 

1588 request_policy=self._command_flags_mapping[command_flag] 

1589 ) 

1590 else: 

1591 command_policies = CommandPolicies() 

1592 elif not command_policies and target_nodes_specified: 

1593 command_policies = CommandPolicies() 

1594 

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

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

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

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

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

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

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

1602 # retry_attempts will be set to 0. 

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

1604 # Add one for the first execution 

1605 execute_attempts = 1 + retry_attempts 

1606 failure_count = 0 

1607 

1608 # Start timing for observability 

1609 start_time = time.monotonic() 

1610 

1611 for _ in range(execute_attempts): 

1612 try: 

1613 res = {} 

1614 if not target_nodes_specified: 

1615 # Determine the nodes to execute the command on 

1616 target_nodes = self._determine_nodes( 

1617 *args, 

1618 request_policy=command_policies.request_policy, 

1619 nodes_flag=passed_targets, 

1620 ) 

1621 

1622 if not target_nodes: 

1623 raise RedisClusterException( 

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

1625 ) 

1626 if ( 

1627 len(target_nodes) == 1 

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

1629 ): 

1630 is_default_node = True 

1631 for node in target_nodes: 

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

1633 

1634 if command_policies.response_policy == ResponsePolicy.ONE_SUCCEEDED: 

1635 break 

1636 

1637 # Return the processed result 

1638 return self._process_result( 

1639 args[0], 

1640 res, 

1641 response_policy=command_policies.response_policy, 

1642 **kwargs, 

1643 ) 

1644 except Exception as e: 

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

1646 if is_default_node: 

1647 # Replace the default cluster node 

1648 self.replace_default_node() 

1649 # The nodes and slots cache were reinitialized. 

1650 # Try again with the new cluster setup. 

1651 retry_attempts -= 1 

1652 failure_count += 1 

1653 

1654 if hasattr(e, "connection"): 

1655 self._record_command_metric( 

1656 command_name=args[0], 

1657 duration_seconds=time.monotonic() - start_time, 

1658 connection=e.connection, 

1659 error=e, 

1660 ) 

1661 

1662 self._record_error_metric( 

1663 error=e, 

1664 connection=e.connection, 

1665 retry_attempts=failure_count, 

1666 ) 

1667 continue 

1668 else: 

1669 # raise the exception 

1670 if hasattr(e, "connection"): 

1671 self._record_error_metric( 

1672 error=e, 

1673 connection=e.connection, 

1674 retry_attempts=failure_count, 

1675 is_internal=False, 

1676 ) 

1677 raise e 

1678 

1679 def _himport_reconcile_discards(self, redis_node, connection): 

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

1681 return _himport_exec.reconcile_discards(redis_node, connection) 

1682 

1683 def _himport_prepare_and_set( 

1684 self, 

1685 redis_node, 

1686 connection, 

1687 key, 

1688 fieldset_name, 

1689 values, 

1690 fieldset, 

1691 asking: bool = False, 

1692 ): 

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

1694 return _himport_exec.prepare_and_set( 

1695 redis_node, 

1696 connection, 

1697 key, 

1698 fieldset_name, 

1699 values, 

1700 fieldset, 

1701 asking=asking, 

1702 ) 

1703 

1704 def _himport_execute_set( 

1705 self, 

1706 redis_node, 

1707 connection, 

1708 key, 

1709 fieldset_name, 

1710 values, 

1711 asking: bool = False, 

1712 ): 

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

1714 return _himport_exec.execute_set( 

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

1716 ) 

1717 

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

1719 """ 

1720 Send a command to a node in the cluster 

1721 """ 

1722 command = args[0] 

1723 redis_node = None 

1724 connection = None 

1725 redirect_addr = None 

1726 asking = False 

1727 moved = False 

1728 ttl = int(self.RedisClusterRequestTTL) 

1729 

1730 # Start timing for observability 

1731 start_time = time.monotonic() 

1732 

1733 while ttl > 0: 

1734 ttl -= 1 

1735 try: 

1736 if asking: 

1737 target_node = self.get_node(node_name=redirect_addr) 

1738 elif moved: 

1739 # MOVED occurred and the slots cache was updated, 

1740 # refresh the target node 

1741 slot = self.determine_slot(*args) 

1742 target_node = self.nodes_manager.get_node_from_slot( 

1743 slot, 

1744 self.read_from_replicas and command in READ_COMMANDS, 

1745 self.load_balancing_strategy 

1746 if command in READ_COMMANDS 

1747 else None, 

1748 ) 

1749 moved = False 

1750 

1751 redis_node = self.get_redis_connection(target_node) 

1752 connection = get_connection(redis_node) 

1753 himport_set = parse_himport_set_args(args) 

1754 if asking and himport_set is None: 

1755 connection.send_command("ASKING") 

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

1757 asking = False 

1758 if himport_set is not None: 

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

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

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

1762 # instead of a client-side IndexError. 

1763 # The cluster 

1764 # executor lazily PREPAREs the fieldset on this connection and 

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

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

1767 # block below. 

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

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

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

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

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

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

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

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

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

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

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

1779 key, fieldset_name, values = himport_set 

1780 ask_himport = asking 

1781 asking = False 

1782 response = self._himport_execute_set( 

1783 redis_node, 

1784 connection, 

1785 key, 

1786 fieldset_name, 

1787 values, 

1788 asking=ask_himport, 

1789 ) 

1790 kwargs.pop("keys", None) 

1791 else: 

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

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

1794 

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

1796 kwargs.pop("keys", None) 

1797 

1798 if command in self.cluster_response_callbacks: 

1799 response = self.cluster_response_callbacks[command]( 

1800 response, **kwargs 

1801 ) 

1802 

1803 self._record_command_metric( 

1804 command_name=command, 

1805 duration_seconds=time.monotonic() - start_time, 

1806 connection=connection, 

1807 ) 

1808 return response 

1809 except AuthenticationError as e: 

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

1811 self._record_command_metric( 

1812 command_name=command, 

1813 duration_seconds=time.monotonic() - start_time, 

1814 connection=e.connection, 

1815 error=e, 

1816 ) 

1817 raise 

1818 except MaxConnectionsError as e: 

1819 # MaxConnectionsError indicates client-side resource exhaustion 

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

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

1822 # without reinitializing the cluster. 

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

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

1825 # because we did not get the connection yet 

1826 e.connection = target_node 

1827 self._record_command_metric( 

1828 command_name=command, 

1829 duration_seconds=time.monotonic() - start_time, 

1830 connection=e.connection, 

1831 error=e, 

1832 ) 

1833 raise 

1834 except (ConnectionError, TimeoutError) as e: 

1835 if is_debug_log_enabled(): 

1836 socket_address = self._extracts_socket_address(connection) 

1837 args_log_str = truncate_text(" ".join(map(safe_str, args))) 

1838 logger.debug( 

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

1840 f"and connection: {connection} using local socket address: {socket_address}, error: {e}" 

1841 ) 

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

1843 e.connection = connection if connection else target_node 

1844 

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

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

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

1848 if connection is not None: 

1849 connection.disconnect() 

1850 

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

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

1853 # by another thread between the check and access 

1854 redis_conn = target_node.redis_connection 

1855 if redis_conn is not None: 

1856 pool = redis_conn.connection_pool 

1857 if pool is not None: 

1858 with pool._lock: 

1859 # take care for the active connections in the pool 

1860 pool.update_active_connections_for_reconnect() 

1861 # disconnect all free connections 

1862 pool.disconnect_free_connections() 

1863 

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

1865 self.nodes_manager.move_node_to_end_of_cached_nodes(target_node.name) 

1866 

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

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

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

1870 self._record_command_metric( 

1871 command_name=command, 

1872 duration_seconds=time.monotonic() - start_time, 

1873 connection=e.connection, 

1874 error=e, 

1875 ) 

1876 raise e 

1877 except MovedError as e: 

1878 if is_debug_log_enabled(): 

1879 socket_address = self._extracts_socket_address(connection) 

1880 args_log_str = truncate_text(" ".join(map(safe_str, args))) 

1881 logger.debug( 

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

1883 f"and connection: {connection} using local socket address: {socket_address}, error: {e}" 

1884 ) 

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

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

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

1888 # reinitializing the tables, and then try again. 

1889 # 'reinitialize_steps' counter will increase faster when 

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

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

1892 # RedisCluster constructor. 

1893 self.reinitialize_counter += 1 

1894 if self._should_reinitialized(): 

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

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

1897 self.nodes_manager.initialize( 

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

1899 ) 

1900 # Reset the counter 

1901 self.reinitialize_counter = 0 

1902 else: 

1903 self.nodes_manager.move_slot(e) 

1904 moved = True 

1905 self._record_command_metric( 

1906 command_name=command, 

1907 duration_seconds=time.monotonic() - start_time, 

1908 connection=connection, 

1909 error=e, 

1910 ) 

1911 self._record_error_metric( 

1912 error=e, 

1913 connection=connection, 

1914 ) 

1915 except TryAgainError as e: 

1916 if is_debug_log_enabled(): 

1917 socket_address = self._extracts_socket_address(connection) 

1918 args_log_str = truncate_text(" ".join(map(safe_str, args))) 

1919 logger.debug( 

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

1921 f"and connection: {connection} using local socket address: {socket_address}" 

1922 ) 

1923 if ttl < self.RedisClusterRequestTTL / 2: 

1924 time.sleep(0.05) 

1925 

1926 self._record_command_metric( 

1927 command_name=command, 

1928 duration_seconds=time.monotonic() - start_time, 

1929 connection=connection, 

1930 error=e, 

1931 ) 

1932 self._record_error_metric( 

1933 error=e, 

1934 connection=connection, 

1935 ) 

1936 except AskError as e: 

1937 if is_debug_log_enabled(): 

1938 socket_address = self._extracts_socket_address(connection) 

1939 args_log_str = truncate_text(" ".join(map(safe_str, args))) 

1940 logger.debug( 

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

1942 f"and connection: {connection} using local socket address: {socket_address}, error: {e}" 

1943 ) 

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

1945 asking = True 

1946 

1947 self._record_command_metric( 

1948 command_name=command, 

1949 duration_seconds=time.monotonic() - start_time, 

1950 connection=connection, 

1951 error=e, 

1952 ) 

1953 self._record_error_metric( 

1954 error=e, 

1955 connection=connection, 

1956 ) 

1957 except (ClusterDownError, SlotNotCoveredError) as e: 

1958 # ClusterDownError can occur during a failover and to get 

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

1960 # and retry executing the command 

1961 

1962 # SlotNotCoveredError can occur when the cluster is not fully 

1963 # initialized or can be temporary issue. 

1964 # We will try to reinitialize the cluster topology 

1965 # and retry executing the command 

1966 

1967 time.sleep(0.25) 

1968 self.nodes_manager.initialize() 

1969 

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

1971 # object which contains the host and port info 

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

1973 e.connection = connection if connection else target_node 

1974 self._record_command_metric( 

1975 command_name=command, 

1976 duration_seconds=time.monotonic() - start_time, 

1977 connection=e.connection, 

1978 error=e, 

1979 ) 

1980 raise 

1981 except ResponseError as e: 

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

1983 # ResponseError typically happens after get_connection() succeeds, 

1984 # so connection should be available 

1985 e.connection = connection if connection else target_node 

1986 self._record_command_metric( 

1987 command_name=command, 

1988 duration_seconds=time.monotonic() - start_time, 

1989 connection=e.connection, 

1990 error=e, 

1991 ) 

1992 raise 

1993 except Exception as e: 

1994 if connection: 

1995 connection.disconnect() 

1996 

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

1998 # object which contains the host and port info 

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

2000 e.connection = connection if connection else target_node 

2001 self._record_command_metric( 

2002 command_name=command, 

2003 duration_seconds=time.monotonic() - start_time, 

2004 connection=e.connection, 

2005 error=e, 

2006 ) 

2007 raise e 

2008 finally: 

2009 if connection is not None: 

2010 redis_node.connection_pool.release(connection) 

2011 

2012 e = ClusterError("TTL exhausted.") 

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

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

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

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

2017 e.connection = connection 

2018 self._record_command_metric( 

2019 command_name=command, 

2020 duration_seconds=time.monotonic() - start_time, 

2021 connection=connection, 

2022 error=e, 

2023 ) 

2024 raise e 

2025 

2026 def _record_command_metric( 

2027 self, 

2028 command_name: str, 

2029 duration_seconds: float, 

2030 connection: Connection, 

2031 error=None, 

2032 ): 

2033 """ 

2034 Records operation duration metric directly. 

2035 """ 

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

2037 port = connection.port if connection else 0 

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

2039 

2040 record_operation_duration( 

2041 command_name=command_name, 

2042 duration_seconds=duration_seconds, 

2043 server_address=host, 

2044 server_port=port, 

2045 db_namespace=db, 

2046 error=error, 

2047 ) 

2048 

2049 def _record_error_metric( 

2050 self, 

2051 error: Exception, 

2052 connection: Connection, 

2053 is_internal: bool = True, 

2054 retry_attempts: Optional[int] = None, 

2055 ): 

2056 """ 

2057 Records error count metric directly. 

2058 """ 

2059 record_error_count( 

2060 server_address=connection.host, 

2061 server_port=connection.port, 

2062 network_peer_address=connection.host, 

2063 network_peer_port=connection.port, 

2064 error_type=error, 

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

2066 is_internal=is_internal, 

2067 ) 

2068 

2069 def _extracts_socket_address( 

2070 self, connection: Optional[Connection] 

2071 ) -> Optional[int]: 

2072 if connection is None: 

2073 return None 

2074 try: 

2075 socket_address = ( 

2076 connection._sock.getsockname() if connection._sock else None 

2077 ) 

2078 socket_address = socket_address[1] if socket_address else None 

2079 except (AttributeError, OSError): 

2080 pass 

2081 return socket_address 

2082 

2083 def close(self) -> None: 

2084 try: 

2085 with self._lock: 

2086 if self.nodes_manager: 

2087 self.nodes_manager.close() 

2088 except AttributeError: 

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

2090 pass 

2091 

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

2093 """ 

2094 Process the result of the executed command. 

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

2096 

2097 :type command: str 

2098 :type res: dict 

2099 

2100 `res` should be in the following format: 

2101 Dict<node_name, command_result> 

2102 """ 

2103 if command in self.result_callbacks: 

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

2105 elif len(res) == 1: 

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

2107 # remove the dictionary and return a single response 

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

2109 

2110 return self._policies_callback_mapping[response_policy](res) 

2111 

2112 def load_external_module(self, funcname, func): 

2113 """ 

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

2115 and their namespaces to the redis client. 

2116 

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

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

2119 """ 

2120 setattr(self, funcname, func) 

2121 

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

2123 """ 

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

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

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

2127 """ 

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

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

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

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

2132 while True: 

2133 try: 

2134 if watches: 

2135 pipe.watch(*watches) 

2136 func_value = func(pipe) 

2137 exec_value = pipe.execute() 

2138 return func_value if value_from_callable else exec_value 

2139 except WatchError: 

2140 if watch_delay is not None and watch_delay > 0: 

2141 time.sleep(watch_delay) 

2142 continue 

2143 

2144 

2145class ClusterNode: 

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

2147 if host == "localhost": 

2148 host = socket.gethostbyname(host) 

2149 

2150 self.host = host 

2151 self.port = port 

2152 self.name = get_node_name(host, port) 

2153 self.server_type = server_type 

2154 self.redis_connection = redis_connection 

2155 

2156 def __repr__(self): 

2157 return ( 

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

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

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

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

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

2163 ) 

2164 

2165 def __eq__(self, obj): 

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

2167 

2168 def __hash__(self): 

2169 return hash(self.name) 

2170 

2171 

2172class LoadBalancingStrategy(Enum): 

2173 ROUND_ROBIN = "round_robin" 

2174 ROUND_ROBIN_REPLICAS = "round_robin_replicas" 

2175 RANDOM = "random" 

2176 RANDOM_REPLICA = "random_replica" 

2177 

2178 

2179class LoadBalancer: 

2180 """ 

2181 Round-Robin Load Balancing 

2182 """ 

2183 

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

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

2186 self.start_index: int = start_index 

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

2188 

2189 def get_server_index( 

2190 self, 

2191 primary: str, 

2192 list_size: int, 

2193 load_balancing_strategy: LoadBalancingStrategy = LoadBalancingStrategy.ROUND_ROBIN, 

2194 ) -> int: 

2195 if load_balancing_strategy == LoadBalancingStrategy.RANDOM_REPLICA: 

2196 return self._get_random_server_index( 

2197 list_size, 

2198 replicas_only=True, 

2199 ) 

2200 elif load_balancing_strategy == LoadBalancingStrategy.RANDOM: 

2201 return self._get_random_server_index( 

2202 list_size, 

2203 replicas_only=False, 

2204 ) 

2205 else: 

2206 return self._get_round_robin_index( 

2207 primary, 

2208 list_size, 

2209 load_balancing_strategy == LoadBalancingStrategy.ROUND_ROBIN_REPLICAS, 

2210 ) 

2211 

2212 def reset(self) -> None: 

2213 with self._lock: 

2214 self.primary_to_idx.clear() 

2215 

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

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

2218 

2219 def _get_round_robin_index( 

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

2221 ) -> int: 

2222 with self._lock: 

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

2224 if replicas_only and server_index == 0: 

2225 # skip the primary node index 

2226 server_index = 1 

2227 # Update the index for the next round 

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

2229 return server_index 

2230 

2231 

2232class NodesManager: 

2233 def __init__( 

2234 self, 

2235 startup_nodes: list[ClusterNode], 

2236 from_url=False, 

2237 require_full_coverage=False, 

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

2239 dynamic_startup_nodes=True, 

2240 connection_pool_class=ConnectionPool, 

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

2242 cache: Optional[CacheInterface] = None, 

2243 cache_config: Optional[CacheConfig] = None, 

2244 cache_factory: Optional[CacheFactoryInterface] = None, 

2245 event_dispatcher: Optional[EventDispatcher] = None, 

2246 maint_notifications_config: Optional[MaintNotificationsConfig] = None, 

2247 himport_registry: HImportRegistry | None = None, 

2248 **kwargs, 

2249 ): 

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

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

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

2253 self.himport_registry = himport_registry 

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

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

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

2257 self.default_node: Optional[ClusterNode] = None 

2258 self._epoch: int = 0 

2259 self.from_url = from_url 

2260 self._require_full_coverage = require_full_coverage 

2261 self._dynamic_startup_nodes = dynamic_startup_nodes 

2262 self.connection_pool_class = connection_pool_class 

2263 self.address_remap = address_remap 

2264 self._cache: Optional[CacheInterface] = None 

2265 if cache: 

2266 self._cache = cache 

2267 elif cache_factory is not None: 

2268 self._cache = cache_factory.get_cache() 

2269 elif cache_config is not None: 

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

2271 self.connection_kwargs = kwargs 

2272 self.read_load_balancer = LoadBalancer() 

2273 

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

2275 if lock is None: 

2276 self._lock = threading.RLock() 

2277 else: 

2278 self._lock = lock 

2279 

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

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

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

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

2284 

2285 if event_dispatcher is None: 

2286 self._event_dispatcher = EventDispatcher() 

2287 else: 

2288 self._event_dispatcher = event_dispatcher 

2289 self._credential_provider = self.connection_kwargs.get( 

2290 "credential_provider", None 

2291 ) 

2292 self.maint_notifications_config = maint_notifications_config 

2293 

2294 self.initialize() 

2295 

2296 def get_node( 

2297 self, 

2298 host: Optional[str] = None, 

2299 port: Optional[int] = None, 

2300 node_name: Optional[str] = None, 

2301 ) -> Optional[ClusterNode]: 

2302 """ 

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

2304 nodes. 

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

2306 """ 

2307 if host and port: 

2308 # the user passed host and port 

2309 if host == "localhost": 

2310 host = socket.gethostbyname(host) 

2311 with self._lock: 

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

2313 elif node_name: 

2314 with self._lock: 

2315 return self.nodes_cache.get(node_name) 

2316 else: 

2317 return None 

2318 

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

2320 """ 

2321 Update the slot's node with the redirected one 

2322 """ 

2323 node_changed = False 

2324 with self._lock: 

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

2326 if redirected_node is not None: 

2327 # The node already exists 

2328 if redirected_node.server_type is not PRIMARY: 

2329 # Update the node's server type 

2330 redirected_node.server_type = PRIMARY 

2331 else: 

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

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

2334 self.nodes_cache[redirected_node.name] = redirected_node 

2335 

2336 slot_nodes = self.slots_cache[e.slot_id] 

2337 if redirected_node not in slot_nodes: 

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

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

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

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

2342 node_changed = True 

2343 elif redirected_node is not slot_nodes[0]: 

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

2345 # had previously been a replica. 

2346 old_primary = slot_nodes[0] 

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

2348 # the slot's node list 

2349 old_primary.server_type = REPLICA 

2350 slot_nodes.append(old_primary) 

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

2352 # node list 

2353 slot_nodes.remove(redirected_node) 

2354 # Override the old primary with the new one 

2355 slot_nodes[0] = redirected_node 

2356 if self.default_node == old_primary: 

2357 # Update the default node with the new primary 

2358 self.default_node = redirected_node 

2359 node_changed = True 

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

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

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

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

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

2365 # listener cannot starve the rest. 

2366 if node_changed: 

2367 try: 

2368 self._event_dispatcher.dispatch(AfterSlotsCacheRefreshEvent()) 

2369 except Exception as exc: 

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

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

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

2373 # out the original AskError/MovedError parameter. 

2374 logger.exception( 

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

2376 type(exc).__name__, 

2377 exc, 

2378 ) 

2379 

2380 @deprecated_args( 

2381 args_to_warn=["server_type"], 

2382 reason=( 

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

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

2385 ), 

2386 version="5.3.0", 

2387 ) 

2388 def get_node_from_slot( 

2389 self, 

2390 slot: int, 

2391 read_from_replicas: bool = False, 

2392 load_balancing_strategy: Optional[LoadBalancingStrategy] = None, 

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

2394 ) -> ClusterNode: 

2395 """ 

2396 Gets a node that servers this hash slot 

2397 """ 

2398 

2399 if read_from_replicas is True and load_balancing_strategy is None: 

2400 load_balancing_strategy = LoadBalancingStrategy.ROUND_ROBIN 

2401 

2402 with self._lock: 

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

2404 raise SlotNotCoveredError( 

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

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

2407 ) 

2408 

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

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

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

2412 node_idx = self.read_load_balancer.get_server_index( 

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

2414 ) 

2415 elif ( 

2416 server_type is None 

2417 or server_type == PRIMARY 

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

2419 ): 

2420 # return a primary 

2421 node_idx = 0 

2422 else: 

2423 # return a replica 

2424 # randomly choose one of the replicas 

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

2426 

2427 return self.slots_cache[slot][node_idx] 

2428 

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

2430 """ 

2431 Get all nodes with the specified server type 

2432 :param server_type: 'primary' or 'replica' 

2433 :return: list of ClusterNode 

2434 """ 

2435 with self._lock: 

2436 return [ 

2437 node 

2438 for node in self.nodes_cache.values() 

2439 if node.server_type == server_type 

2440 ] 

2441 

2442 @deprecated_function( 

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

2444 version="7.0.2", 

2445 ) 

2446 def populate_startup_nodes(self, nodes): 

2447 """ 

2448 Populate all startup nodes and filters out any duplicates 

2449 """ 

2450 with self._lock: 

2451 for n in nodes: 

2452 self.startup_nodes[n.name] = n 

2453 

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

2455 """ 

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

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

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

2459 """ 

2460 # Move in startup_nodes 

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

2462 node = self.startup_nodes.pop(node_name) 

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

2464 

2465 # Move in nodes_cache - this affects get_nodes_by_server_type ordering 

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

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

2468 node = self.nodes_cache.pop(node_name) 

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

2470 

2471 def check_slots_coverage(self, slots_cache): 

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

2473 # startup node 

2474 for i in range(0, REDIS_CLUSTER_HASH_SLOTS): 

2475 if i not in slots_cache: 

2476 return False 

2477 return True 

2478 

2479 def create_redis_connections(self, nodes): 

2480 """ 

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

2482 """ 

2483 connection_pools = [] 

2484 for node in nodes: 

2485 if node.redis_connection is None: 

2486 node.redis_connection = self.create_redis_node( 

2487 host=node.host, 

2488 port=node.port, 

2489 maint_notifications_config=self.maint_notifications_config, 

2490 **self.connection_kwargs, 

2491 ) 

2492 connection_pools.append(node.redis_connection.connection_pool) 

2493 

2494 self._event_dispatcher.dispatch( 

2495 AfterPooledConnectionsInstantiationEvent( 

2496 connection_pools, ClientType.SYNC, self._credential_provider 

2497 ) 

2498 ) 

2499 

2500 def create_redis_node( 

2501 self, 

2502 host, 

2503 port, 

2504 **kwargs, 

2505 ): 

2506 # We are configuring the connection pool not to retry 

2507 # connections on lower level clients to avoid retrying 

2508 # connections to nodes that are not reachable 

2509 # and to avoid blocking the connection pool. 

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

2511 # level clients is ConnectionError which will trigger disconnection 

2512 # of the socket. 

2513 # The retries will be handled on cluster client level 

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

2515 node_retry_config = Retry( 

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

2517 ) 

2518 

2519 if self.from_url: 

2520 # Create a redis node with a custom connection pool 

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

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

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

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

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

2526 else: 

2527 r = Redis( 

2528 host=host, 

2529 port=port, 

2530 cache=self._cache, 

2531 retry=node_retry_config, 

2532 **kwargs, 

2533 ) 

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

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

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

2537 if self.himport_registry is not None: 

2538 r.connection_pool.himport_registry = self.himport_registry 

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

2540 self.himport_registry 

2541 ) 

2542 return r 

2543 

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

2545 node_name = get_node_name(host, port) 

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

2547 target_node = tmp_nodes_cache.get(node_name) 

2548 if target_node is None: 

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

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

2551 # reuse it 

2552 redis_connection: Optional[Redis] = None 

2553 with self._lock: 

2554 previous_node = self.nodes_cache.get(node_name) 

2555 if previous_node: 

2556 redis_connection = previous_node.redis_connection 

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

2558 # outside of the lock 

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

2560 # add this node to the nodes cache 

2561 tmp_nodes_cache[target_node.name] = target_node 

2562 

2563 return target_node 

2564 

2565 def _get_epoch(self) -> int: 

2566 """ 

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

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

2569 """ 

2570 with self._lock: 

2571 return self._epoch 

2572 

2573 def initialize( 

2574 self, 

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

2576 disconnect_startup_nodes_pools: bool = True, 

2577 last_failed_node_name: Optional[str] = None, 

2578 ): 

2579 """ 

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

2581 :startup_nodes: 

2582 Responsible for discovering other nodes in the cluster 

2583 :disconnect_startup_nodes_pools: 

2584 Whether to disconnect the connection pool of the startup nodes 

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

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

2587 keeping the connection open. 

2588 :additional_startup_nodes_info: 

2589 Additional nodes to add temporarily to the startup nodes. 

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

2591 and nodes information from the cluster. 

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

2593 and initialize the client 

2594 with them. 

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

2596 the host and port of the node. 

2597 :last_failed_node_name: 

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

2599 other startup and additional startup nodes during this refresh. 

2600 """ 

2601 self.reset() 

2602 tmp_nodes_cache = {} 

2603 tmp_slots = {} 

2604 disagreements = [] 

2605 startup_nodes_reachable = False 

2606 fully_covered = False 

2607 kwargs = self.connection_kwargs 

2608 exception = None 

2609 epoch = self._get_epoch() 

2610 if additional_startup_nodes_info is None: 

2611 additional_startup_nodes_info = [] 

2612 

2613 with self._initialization_lock: 

2614 with self._lock: 

2615 if epoch != self._epoch: 

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

2617 # bother running again 

2618 return 

2619 

2620 with self._lock: 

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

2622 deferred_failed_nodes = [] 

2623 if last_failed_node_name is not None: 

2624 for index, node in enumerate(startup_nodes): 

2625 if node.name == last_failed_node_name: 

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

2627 break 

2628 if len(startup_nodes) > 1: 

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

2630 # all reinitialize through the same node. 

2631 random.shuffle(startup_nodes) 

2632 

2633 additional_startup_nodes = [ 

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

2635 ] 

2636 if last_failed_node_name is not None: 

2637 for index, node in enumerate(additional_startup_nodes): 

2638 if node.name == last_failed_node_name: 

2639 if not deferred_failed_nodes: 

2640 deferred_failed_nodes.append(node) 

2641 additional_startup_nodes.pop(index) 

2642 break 

2643 if is_debug_log_enabled(): 

2644 logger.debug( 

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

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

2647 ) 

2648 

2649 for startup_node in chain( 

2650 startup_nodes, 

2651 additional_startup_nodes, 

2652 deferred_failed_nodes, 

2653 ): 

2654 try: 

2655 if startup_node.redis_connection: 

2656 r = startup_node.redis_connection 

2657 

2658 else: 

2659 # Create a new Redis connection 

2660 if is_debug_log_enabled(): 

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

2662 socket_connect_timeout = kwargs.get( 

2663 "socket_connect_timeout", "not set" 

2664 ) 

2665 maint_enabled = ( 

2666 self.maint_notifications_config.enabled 

2667 if self.maint_notifications_config 

2668 else False 

2669 ) 

2670 logger.debug( 

2671 "Topology refresh: Creating new Redis connection to " 

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

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

2674 f"socket_connect_timeout: {socket_connect_timeout}, " 

2675 "and maint_notifications enabled: " 

2676 f"{maint_enabled}" 

2677 ) 

2678 r = self.create_redis_node( 

2679 startup_node.host, 

2680 startup_node.port, 

2681 maint_notifications_config=self.maint_notifications_config, 

2682 **kwargs, 

2683 ) 

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

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

2686 else: 

2687 startup_node.redis_connection = r 

2688 try: 

2689 # Make sure cluster mode is enabled on this node 

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

2691 if disconnect_startup_nodes_pools: 

2692 with r.connection_pool._lock: 

2693 # take care to clear connections before we move on 

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

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

2696 r.connection_pool.update_active_connections_for_reconnect() 

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

2698 r.connection_pool.disconnect_free_connections() 

2699 except ResponseError: 

2700 raise RedisClusterException( 

2701 "Cluster mode is not enabled on this node" 

2702 ) 

2703 startup_nodes_reachable = True 

2704 except Exception as e: 

2705 # Try the next startup node. 

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

2707 exception = e 

2708 continue 

2709 

2710 # CLUSTER SLOTS command results in the following output: 

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

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

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

2714 # primary node of the first slot section. 

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

2716 # Fix it to the host in startup_nodes 

2717 if ( 

2718 len(cluster_slots) == 1 

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

2720 and len(self.startup_nodes) == 1 

2721 ): 

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

2723 

2724 for slot in cluster_slots: 

2725 primary_node = slot[2] 

2726 host = str_if_bytes(primary_node[0]) 

2727 if host == "": 

2728 host = startup_node.host 

2729 port = int(primary_node[1]) 

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

2731 

2732 nodes_for_slot = [] 

2733 

2734 target_node = self._get_or_create_cluster_node( 

2735 host, port, PRIMARY, tmp_nodes_cache 

2736 ) 

2737 nodes_for_slot.append(target_node) 

2738 

2739 replica_nodes = slot[3:] 

2740 for replica_node in replica_nodes: 

2741 host = str_if_bytes(replica_node[0]) 

2742 port = int(replica_node[1]) 

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

2744 target_replica_node = self._get_or_create_cluster_node( 

2745 host, port, REPLICA, tmp_nodes_cache 

2746 ) 

2747 nodes_for_slot.append(target_replica_node) 

2748 

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

2750 if i not in tmp_slots: 

2751 tmp_slots[i] = nodes_for_slot 

2752 else: 

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

2754 # setup 

2755 tmp_slot = tmp_slots[i][0] 

2756 if tmp_slot.name != target_node.name: 

2757 disagreements.append( 

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

2759 ) 

2760 

2761 if len(disagreements) > 5: 

2762 raise RedisClusterException( 

2763 f"startup_nodes could not agree on a valid " 

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

2765 ) 

2766 

2767 fully_covered = self.check_slots_coverage(tmp_slots) 

2768 if fully_covered: 

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

2770 # slots are covered 

2771 break 

2772 

2773 if not startup_nodes_reachable: 

2774 raise RedisClusterException( 

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

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

2777 ) from exception 

2778 

2779 # Create Redis connections to all nodes 

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

2781 

2782 # Check if the slots are not fully covered 

2783 if not fully_covered and self._require_full_coverage: 

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

2785 # isn't a full coverage 

2786 raise RedisClusterException( 

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

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

2789 f"covered..." 

2790 ) 

2791 

2792 # Set the tmp variables to the real variables 

2793 with self._lock: 

2794 self.nodes_cache = tmp_nodes_cache 

2795 self.slots_cache = tmp_slots 

2796 # Set the default node 

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

2798 if self._dynamic_startup_nodes: 

2799 # Populate the startup nodes with all discovered nodes 

2800 self.startup_nodes = tmp_nodes_cache 

2801 # Increment the epoch to signal that initialization has completed 

2802 self._epoch += 1 

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

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

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

2806 # listener cannot starve the rest. 

2807 try: 

2808 self._event_dispatcher.dispatch(AfterSlotsCacheRefreshEvent()) 

2809 except Exception as e: 

2810 logger.exception( 

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

2812 type(e).__name__, 

2813 e, 

2814 ) 

2815 

2816 def close(self) -> None: 

2817 with self._lock: 

2818 self.default_node = None 

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

2820 for node in nodes: 

2821 if node.redis_connection: 

2822 node.redis_connection.close() 

2823 

2824 def reset(self): 

2825 try: 

2826 self.read_load_balancer.reset() 

2827 except TypeError: 

2828 # The read_load_balancer is None, do nothing 

2829 pass 

2830 

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

2832 """ 

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

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

2835 to the cluster. 

2836 """ 

2837 if self.address_remap: 

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

2839 return host, port 

2840 

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

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

2843 with self._lock: 

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

2845 if node.redis_connection: 

2846 conn_args = node.redis_connection.connection_pool.connection_kwargs 

2847 if node_name == get_node_name( 

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

2849 ): 

2850 return node 

2851 return None 

2852 

2853 

2854def _unregister_slots_cache_listener( 

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

2856 listener: EventListenerInterface, 

2857 event_type: Type[object], 

2858) -> None: 

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

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

2861 # extend the pubsub's lifetime. 

2862 dispatcher = dispatcher_ref() 

2863 if dispatcher is not None: 

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

2865 

2866 

2867class ClusterPubSubSlotsCacheListener(EventListenerInterface): 

2868 """ 

2869 Listener that forwards AfterSlotsCacheRefreshEvent to a ClusterPubSub. 

2870 

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

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

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

2874 ClusterPubSub in ``ClusterPubSub.__init__``. 

2875 """ 

2876 

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

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

2879 

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

2881 pubsub = self._pubsub_ref() 

2882 if pubsub is None: 

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

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

2885 return 

2886 try: 

2887 pubsub.on_slots_changed() 

2888 except Exception as e: 

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

2890 # a single buggy pubsub cannot starve the rest. 

2891 logger.exception( 

2892 "pubsub %r raised during slots-cache change: %s: %s", 

2893 pubsub, 

2894 type(e).__name__, 

2895 e, 

2896 ) 

2897 

2898 

2899class ClusterPubSub(PubSub): 

2900 """ 

2901 Wrapper for PubSub class. 

2902 

2903 IMPORTANT: before using ClusterPubSub, read about the known limitations 

2904 with pubsub in Cluster mode and learn how to workaround them: 

2905 https://redis.readthedocs.io/en/stable/clustering.html#known-pubsub-limitations 

2906 """ 

2907 

2908 def __init__( 

2909 self, 

2910 redis_cluster, 

2911 node=None, 

2912 host=None, 

2913 port=None, 

2914 push_handler_func=None, 

2915 event_dispatcher: Optional["EventDispatcher"] = None, 

2916 **kwargs, 

2917 ): 

2918 """ 

2919 When a pubsub instance is created without specifying a node, a single 

2920 node will be transparently chosen for the pubsub connection on the 

2921 first command execution. The node will be determined by: 

2922 1. Hashing the channel name in the request to find its keyslot 

2923 2. Selecting a node that handles the keyslot: If read_from_replicas is 

2924 set to true or load_balancing_strategy is set, a replica can be selected. 

2925 

2926 :type redis_cluster: RedisCluster 

2927 :type node: ClusterNode 

2928 :type host: str 

2929 :type port: int 

2930 """ 

2931 self.node = None 

2932 self.set_pubsub_node(redis_cluster, node, host, port) 

2933 connection_pool = ( 

2934 None 

2935 if self.node is None 

2936 else redis_cluster.get_redis_connection(self.node).connection_pool 

2937 ) 

2938 self.cluster = redis_cluster 

2939 self.node_pubsub_mapping = {} 

2940 # Reverse index: shard channel (normalized) -> owning node.name. Used to 

2941 # route sunsubscribe calls and reconcile subscriptions after slot 

2942 # migration / failover. 

2943 self._shard_channel_to_node: dict = {} 

2944 # Dedicated lock for shard-subscription bookkeeping. Distinct from 

2945 # PubSub.self._lock (which serializes wire I/O on the cluster-level 

2946 # connection used by aclose / send_command / regular subscribe) so 

2947 # that reconciliation cannot starve those unrelated paths during 

2948 # long per-channel migrations. 

2949 self._shard_state_lock: threading.RLock = threading.RLock() 

2950 # Worker executor for off-loading slot-migration reconciliation from 

2951 # the dispatch call site (mirrors async's asyncio.create_task model so 

2952 # the thread that triggered MovedError / topology refresh is not 

2953 # blocked on per-channel sunsubscribe / ssubscribe network I/O). 

2954 # Lazy-created on first on_slots_changed() to avoid a persistent 

2955 # worker thread for pubsubs that never see a slot migration. 

2956 # Initialized before super().__init__() because PubSub.__init__ calls 

2957 # self.reset(), which resolves to ClusterPubSub.reset() and reads 

2958 # these attributes. 

2959 self._reconcile_executor: Optional[ThreadPoolExecutor] = None 

2960 # In-flight reconciliation futures; tracked so reset() can cancel 

2961 # pending work and so exceptions surface via a done-callback. 

2962 self._reconcile_futures: Set[Future] = set() 

2963 self._pubsubs_generator = self._pubsubs_generator() 

2964 if event_dispatcher is None: 

2965 self._event_dispatcher = EventDispatcher() 

2966 else: 

2967 self._event_dispatcher = event_dispatcher 

2968 super().__init__( 

2969 connection_pool=connection_pool, 

2970 encoder=redis_cluster.encoder, 

2971 push_handler_func=push_handler_func, 

2972 event_dispatcher=self._event_dispatcher, 

2973 **kwargs, 

2974 ) 

2975 # Subscribe to slots-cache change notifications so shard subscriptions 

2976 # can be reconciled automatically after topology refreshes. 

2977 nm_dispatcher = redis_cluster.nodes_manager._event_dispatcher 

2978 self._slots_cache_listener = ClusterPubSubSlotsCacheListener(self) 

2979 nm_dispatcher.register_listeners( 

2980 {AfterSlotsCacheRefreshEvent: [self._slots_cache_listener]} 

2981 ) 

2982 # Deterministic GC-time cleanup so short-lived pubsubs do not leak 

2983 # listeners in the dispatcher when no slots-refresh event ever fires. 

2984 weakref.finalize( 

2985 self, 

2986 _unregister_slots_cache_listener, 

2987 weakref.ref(nm_dispatcher), 

2988 self._slots_cache_listener, 

2989 AfterSlotsCacheRefreshEvent, 

2990 ) 

2991 

2992 def set_pubsub_node(self, cluster, node=None, host=None, port=None): 

2993 """ 

2994 The pubsub node will be set according to the passed node, host and port 

2995 When none of the node, host, or port are specified - the node is set 

2996 to None and will be determined by the keyslot of the channel in the 

2997 first command to be executed. 

2998 RedisClusterException will be thrown if the passed node does not exist 

2999 in the cluster. 

3000 If host is passed without port, or vice versa, a DataError will be 

3001 thrown. 

3002 :type cluster: RedisCluster 

3003 :type node: ClusterNode 

3004 :type host: str 

3005 :type port: int 

3006 """ 

3007 if node is not None: 

3008 # node is passed by the user 

3009 self._raise_on_invalid_node(cluster, node, node.host, node.port) 

3010 pubsub_node = node 

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

3012 # host and port passed by the user 

3013 node = cluster.get_node(host=host, port=port) 

3014 self._raise_on_invalid_node(cluster, node, host, port) 

3015 pubsub_node = node 

3016 elif any([host, port]) is True: 

3017 # only 'host' or 'port' passed 

3018 raise DataError("Passing a host requires passing a port, and vice versa") 

3019 else: 

3020 # nothing passed by the user. set node to None 

3021 pubsub_node = None 

3022 

3023 self.node = pubsub_node 

3024 

3025 def get_pubsub_node(self): 

3026 """ 

3027 Get the node that is being used as the pubsub connection 

3028 """ 

3029 return self.node 

3030 

3031 def _raise_on_invalid_node(self, redis_cluster, node, host, port): 

3032 """ 

3033 Raise a RedisClusterException if the node is None or doesn't exist in 

3034 the cluster. 

3035 """ 

3036 if node is None or redis_cluster.get_node(node_name=node.name) is None: 

3037 raise RedisClusterException( 

3038 f"Node {host}:{port} doesn't exist in the cluster" 

3039 ) 

3040 

3041 def execute_command(self, *args): 

3042 """ 

3043 Execute a subscribe/unsubscribe command. 

3044 

3045 Taken code from redis-py and tweak to make it work within a cluster. 

3046 """ 

3047 # NOTE: don't parse the response in this function -- it could pull a 

3048 # legitimate message off the stack if the connection is already 

3049 # subscribed to one or more channels 

3050 

3051 if self.connection is None: 

3052 if self.connection_pool is None: 

3053 if len(args) > 1: 

3054 # Hash the first channel and get one of the nodes holding 

3055 # this slot 

3056 channel = args[1] 

3057 slot = self.cluster.keyslot(channel) 

3058 node = self.cluster.nodes_manager.get_node_from_slot( 

3059 slot, 

3060 self.cluster.read_from_replicas, 

3061 self.cluster.load_balancing_strategy, 

3062 ) 

3063 else: 

3064 # Get a random node 

3065 node = self.cluster.get_random_node() 

3066 self.node = node 

3067 redis_connection = self.cluster.get_redis_connection(node) 

3068 self.connection_pool = redis_connection.connection_pool 

3069 self.connection = self.connection_pool.get_connection() 

3070 # register a callback that re-subscribes to any channels we 

3071 # were listening to when we were disconnected 

3072 self.connection.register_connect_callback(self.on_connect) 

3073 if self.push_handler_func is not None: 

3074 self.connection._parser.set_pubsub_push_handler(self.push_handler_func) 

3075 self._event_dispatcher.dispatch( 

3076 AfterPubSubConnectionInstantiationEvent( 

3077 self.connection, self.connection_pool, ClientType.SYNC, self._lock 

3078 ) 

3079 ) 

3080 connection = self.connection 

3081 self._execute(connection, connection.send_command, *args) 

3082 

3083 def _resubscribe_shard_channels(self) -> None: 

3084 # A single node can own multiple slot ranges, so a batched 

3085 # ``SSUBSCRIBE`` covering every tracked channel would be rejected by 

3086 # Redis with a ``CROSSSLOT`` error. Group by hash slot and emit one 

3087 # ``SSUBSCRIBE`` per slot. 

3088 by_slot: defaultdict[int, dict] = defaultdict(dict) 

3089 for k, v in self.shard_channels.items(): 

3090 by_slot[key_slot(self.encoder.encode(k))][k] = v 

3091 for subscriptions in by_slot.values(): 

3092 self._resubscribe(subscriptions, self.ssubscribe) 

3093 

3094 def _get_node_pubsub(self, node): 

3095 try: 

3096 return self.node_pubsub_mapping[node.name] 

3097 except KeyError: 

3098 redis_connection = self.cluster.get_redis_connection(node) 

3099 pubsub = redis_connection.pubsub( 

3100 push_handler_func=self.push_handler_func, 

3101 ) 

3102 # Replay shard subscriptions on reconnect with slot-aware grouping 

3103 # so that channels spanning multiple slots owned by this node do 

3104 # not trigger a CROSSSLOT error. 

3105 pubsub._resubscribe_shard_channels = MethodType( 

3106 ClusterPubSub._resubscribe_shard_channels, pubsub 

3107 ) 

3108 self.node_pubsub_mapping[node.name] = pubsub 

3109 return pubsub 

3110 

3111 def _find_node_name_for_pubsub(self, pubsub): 

3112 for node_name, node_pubsub in self.node_pubsub_mapping.items(): 

3113 if node_pubsub is pubsub: 

3114 return node_name 

3115 return None 

3116 

3117 def _sharded_message_generator(self, timeout=0.0): 

3118 for _ in range(len(self.node_pubsub_mapping)): 

3119 pubsub = next(self._pubsubs_generator) 

3120 # Don't pass ignore_subscribe_messages here - let get_sharded_message 

3121 # handle the filtering after processing subscription state changes 

3122 message = pubsub.get_message( 

3123 ignore_subscribe_messages=False, timeout=timeout 

3124 ) 

3125 if message is not None: 

3126 return pubsub, message 

3127 return None, None 

3128 

3129 def _pubsubs_generator(self): 

3130 while True: 

3131 current_nodes = list(self.node_pubsub_mapping.values()) 

3132 if not current_nodes: 

3133 return # Avoid infinite loop when no subscriptions exist 

3134 yield from current_nodes 

3135 

3136 def get_sharded_message( 

3137 self, ignore_subscribe_messages=False, timeout=0.0, target_node=None 

3138 ): 

3139 if target_node: 

3140 # Use .get(): migration-driven cleanup in the sunsubscribe branch 

3141 # below and reset() both remove entries from node_pubsub_mapping, 

3142 # so a caller polling with target_node may race the cleanup. Match 

3143 # the async counterpart's None-handling rather than raising 

3144 # KeyError. None pubsub falls through to "no message available". 

3145 pubsub = self.node_pubsub_mapping.get(target_node.name) 

3146 if pubsub is not None: 

3147 # Don't pass ignore_subscribe_messages here - let get_sharded_message 

3148 # handle the filtering after processing subscription state changes 

3149 message = pubsub.get_message( 

3150 ignore_subscribe_messages=False, timeout=timeout 

3151 ) 

3152 else: 

3153 message = None 

3154 else: 

3155 pubsub, message = self._sharded_message_generator(timeout=timeout) 

3156 if message is None: 

3157 return None 

3158 # Only sunsubscribe mutates cluster-level shard state; bypassing the 

3159 # lock on the data-message hot path keeps smessage delivery from 

3160 # competing with the reconciliation worker for _shard_state_lock. 

3161 if str_if_bytes(message["type"]) == "sunsubscribe": 

3162 # Serialize state mutation against reinitialize_shard_subscriptions 

3163 # (worker thread). The blocking get_message above intentionally 

3164 # runs outside the lock so reconciliation is not stalled by long 

3165 # polls. 

3166 with self._shard_state_lock: 

3167 if message["channel"] in self.pending_unsubscribe_shard_channels: 

3168 # User-initiated sunsubscribe: drop from cluster-level tracking. 

3169 self.pending_unsubscribe_shard_channels.remove(message["channel"]) 

3170 self.shard_channels.pop(message["channel"], None) 

3171 self._shard_channel_to_node.pop(message["channel"], None) 

3172 # Drop the per-node pubsub that delivered the confirmation once 

3173 # it no longer holds any shard subscriptions, regardless of 

3174 # whether the sunsubscribe was user-initiated or driven by 

3175 # slot-migration reconciliation (_migrate_shard_channel, which 

3176 # intentionally does not add the channel to 

3177 # pending_unsubscribe_shard_channels). This releases the 

3178 # dedicated connection that would otherwise linger. 

3179 # Identifying the receiving pubsub directly (rather than via 

3180 # the cluster's current slot map) is required after slot 

3181 # migration, where the channel's owner is no longer the node 

3182 # that received our original SSUBSCRIBE. 

3183 if pubsub is not None and not pubsub.subscribed: 

3184 name = self._find_node_name_for_pubsub(pubsub) 

3185 if name is not None: 

3186 try: 

3187 pubsub.reset() 

3188 except Exception: 

3189 pass 

3190 self.node_pubsub_mapping.pop(name, None) 

3191 # Mirror PubSub.handle_message: the empty-check belongs in the 

3192 # unsubscribe branch since that is the only path that can 

3193 # reduce shard_channels here. 

3194 if not self.channels and not self.patterns and not self.shard_channels: 

3195 self.subscribed_event.clear() 

3196 # Only suppress subscribe/unsubscribe messages, not data messages (smessage) 

3197 if str_if_bytes(message["type"]) in ("ssubscribe", "sunsubscribe"): 

3198 if self.ignore_subscribe_messages or ignore_subscribe_messages: 

3199 return None 

3200 return message 

3201 

3202 def ssubscribe( 

3203 self, *args: ChannelT | Subscription, **kwargs: PubSubHandler 

3204 ) -> None: 

3205 """ 

3206 Subscribe to shard channels. 

3207 

3208 Channels supplied as keyword arguments expect a channel name as the key 

3209 and a callable as the value. ``Subscription`` objects can also be 

3210 supplied positionally with an optional handler. 

3211 """ 

3212 s_channels = parse_pubsub_subscriptions(args, kwargs) 

3213 # Serialize against reinitialize_shard_subscriptions (worker thread) 

3214 # so the reverse index, shard_channels, and node_pubsub_mapping are 

3215 # not mutated concurrently. 

3216 with self._shard_state_lock: 

3217 for s_channel, handler in s_channels.items(): 

3218 node = self.cluster.get_node_from_key(s_channel) 

3219 if not node: 

3220 continue 

3221 # Lazy re-route: if this channel is already tracked against a 

3222 # different node (e.g. after a slot migration), migrate it now 

3223 # so the caller's intent is applied on the current owner. 

3224 normalized_key = next(iter(self._normalize_keys({s_channel: None}))) 

3225 old_name = self._shard_channel_to_node.get(normalized_key) 

3226 if old_name and old_name != node.name: 

3227 # Match PubSub.ssubscribe() dict.update() semantics: the 

3228 # caller's newly supplied handler (including None) always 

3229 # overrides any previously registered handler. 

3230 self._migrate_shard_channel( 

3231 normalized_key, 

3232 handler, 

3233 old_name, 

3234 node, 

3235 ) 

3236 continue 

3237 pubsub = self._get_node_pubsub(node) 

3238 if handler: 

3239 pubsub.ssubscribe(Subscription(s_channel, handler)) 

3240 else: 

3241 pubsub.ssubscribe(s_channel) 

3242 self.shard_channels.update(pubsub.shard_channels) 

3243 self._shard_channel_to_node[normalized_key] = node.name 

3244 self.pending_unsubscribe_shard_channels.difference_update( 

3245 self._normalize_keys({s_channel: None}) 

3246 ) 

3247 if pubsub.subscribed and not self.subscribed: 

3248 self.subscribed_event.set() 

3249 self.health_check_response_counter = 0 

3250 

3251 def sunsubscribe(self, *args): 

3252 if args: 

3253 args = list_or_args(args[0], args[1:]) 

3254 else: 

3255 args = list(self.shard_channels) 

3256 

3257 # Serialize against reinitialize_shard_subscriptions: the reverse 

3258 # index and node_pubsub_mapping must not change between the lookup 

3259 # and the per-node sunsubscribe call below. 

3260 with self._shard_state_lock: 

3261 for s_channel in args: 

3262 normalized_key = next(iter(self._normalize_keys({s_channel: None}))) 

3263 # Route via the reverse index so we unsubscribe on the node 

3264 # that actually holds the subscription. After a slot migration 

3265 # the cluster's current owner may no longer be that node. 

3266 name = self._shard_channel_to_node.get(normalized_key) 

3267 if name and name in self.node_pubsub_mapping: 

3268 p = self.node_pubsub_mapping[name] 

3269 else: 

3270 node = self.cluster.get_node_from_key(s_channel) 

3271 if not node or node.name not in self.node_pubsub_mapping: 

3272 continue 

3273 p = self.node_pubsub_mapping[node.name] 

3274 p.sunsubscribe(s_channel) 

3275 self.pending_unsubscribe_shard_channels.update( 

3276 p.pending_unsubscribe_shard_channels 

3277 ) 

3278 

3279 def reinitialize_shard_subscriptions(self): 

3280 """ 

3281 Reconcile per-node shard subscriptions against the cluster's current 

3282 slot ownership map. For each tracked shard channel whose owning node 

3283 has changed (e.g. after CLUSTER SETSLOT / failover), sunsubscribe on 

3284 the old node's pubsub and ssubscribe on the new owner's pubsub, 

3285 preserving any registered handler. 

3286 """ 

3287 uncovered: list = [] 

3288 made_progress = False 

3289 first_migrate_error: Optional[BaseException] = None 

3290 with self._shard_state_lock: 

3291 for channel, handler in list(self.shard_channels.items()): 

3292 try: 

3293 new_node = self.cluster.get_node_from_key(channel) 

3294 except SlotNotCoveredError: 

3295 # Slot is transiently uncovered (mid-migration / partial 

3296 # topology refresh). Defer this channel so coverable 

3297 # siblings still reconcile this pass; we surface the 

3298 # error below so the caller (and logs) know not every 

3299 # channel was reconciled. Retry happens on the next 

3300 # slots-cache change notification. 

3301 uncovered.append(channel) 

3302 continue 

3303 old_name = self._shard_channel_to_node.get(channel) 

3304 if old_name == new_node.name: 

3305 continue 

3306 try: 

3307 self._migrate_shard_channel(channel, handler, old_name, new_node) 

3308 made_progress = True 

3309 except (ConnectionError, TimeoutError, OSError) as e: 

3310 # Transient connectivity error while subscribing on the 

3311 # new owner (or unsubscribing on the old owner if its 

3312 # handler chose to re-raise). Do not abort reconciliation 

3313 # for sibling channels: _shard_channel_to_node was not 

3314 # advanced for this channel, so the next slots-cache 

3315 # change notification will retry it. 

3316 logger.warning( 

3317 "shard channel %r migration deferred: %s: %s", 

3318 channel, 

3319 type(e).__name__, 

3320 e, 

3321 ) 

3322 if first_migrate_error is None: 

3323 first_migrate_error = e 

3324 continue 

3325 # Garbage-collect per-node pubsubs that no longer hold any 

3326 # subscription so their connections are released. 

3327 for name, pubsub in list(self.node_pubsub_mapping.items()): 

3328 if not pubsub.subscribed: 

3329 try: 

3330 pubsub.reset() 

3331 except Exception: 

3332 pass 

3333 self.node_pubsub_mapping.pop(name, None) 

3334 if uncovered: 

3335 # Surface the uncovered channels so the caller (and observer 

3336 # notification path) knows reconciliation was incomplete. All 

3337 # coverable siblings have already been migrated above. 

3338 raise SlotNotCoveredError( 

3339 f"{len(uncovered)} shard channel(s) left unreconciled; " 

3340 f"slot(s) not covered by the cluster: {uncovered!r}" 

3341 ) 

3342 if first_migrate_error is not None and not made_progress: 

3343 # Every migration attempted in this pass failed transiently and 

3344 # nothing else made progress. Re-raise the first caught error 

3345 # (typically the root cause; later failures are often downstream 

3346 # symptoms of the same unreachable node) so the worker's done- 

3347 # callback surfaces a single representative failure through the 

3348 # same logger channel used for SlotNotCoveredError. Per-channel 

3349 # WARNINGs above preserve the full forensic detail. 

3350 raise first_migrate_error 

3351 

3352 def _migrate_shard_channel(self, channel, handler, old_name, new_node): 

3353 # Detach from the old per-node pubsub, best-effort: the old node may 

3354 # already be unreachable during migration / failover. 

3355 if old_name and old_name in self.node_pubsub_mapping: 

3356 old_pubsub = self.node_pubsub_mapping[old_name] 

3357 try: 

3358 old_pubsub.sunsubscribe(channel) 

3359 except (ConnectionError, TimeoutError, OSError): 

3360 # redis-py's Connection has already called ``disconnect()`` 

3361 # before raising (see Connection.read_response / 

3362 # send_packed_command with ``disconnect_on_error=True``), 

3363 # so ``old_pubsub``'s dedicated socket is gone. Two cases: 

3364 # 

3365 # 1. The old node is no longer in the cluster topology 

3366 # (e.g. removed by failover / topology refresh): no 

3367 # reconnect target exists, so ``old_pubsub.subscribed`` 

3368 # would stay True forever and the end-of-pass GC block 

3369 # would skip it. Drop it eagerly so the round-robin 

3370 # generator does not keep yielding a dead pubsub that 

3371 # produces periodic errors from ``get_sharded_message``. 

3372 # 2. The old node is still known (transiently slow / 

3373 # unreachable): ``PubSub._execute`` auto-reconnects and 

3374 # ``on_connect`` re-subscribes to remaining channels, 

3375 # so other subscriptions on the same pubsub recover 

3376 # naturally. Leave it alone. 

3377 if self.cluster.get_node(node_name=old_name) is None: 

3378 try: 

3379 old_pubsub.reset() 

3380 except Exception: 

3381 pass 

3382 self.node_pubsub_mapping.pop(old_name, None) 

3383 # Attach to the new per-node pubsub, preserving the handler. Decode to 

3384 # a text key only when we must pass it as a kwarg (handler present). 

3385 new_pubsub = self._get_node_pubsub(new_node) 

3386 if handler: 

3387 new_pubsub.ssubscribe(Subscription(channel, handler)) 

3388 else: 

3389 new_pubsub.ssubscribe(channel) 

3390 self.shard_channels.update(new_pubsub.shard_channels) 

3391 normalized_key = next(iter(self._normalize_keys({channel: None}))) 

3392 self._shard_channel_to_node[normalized_key] = new_node.name 

3393 self.pending_unsubscribe_shard_channels.difference_update( 

3394 self._normalize_keys({channel: None}) 

3395 ) 

3396 if new_pubsub.subscribed and not self.subscribed: 

3397 self.subscribed_event.set() 

3398 self.health_check_response_counter = 0 

3399 

3400 def on_slots_changed(self): 

3401 # Observer hook invoked by NodesManager after a slots-cache refresh. 

3402 # Schedule reconciliation on a dedicated worker thread so the caller 

3403 # (typically MovedError handling in _execute_command or the topology 

3404 # refresh thread in initialize()) is not blocked on the network I/O 

3405 # performed by reinitialize_shard_subscriptions. Mirrors the async 

3406 # path's asyncio.create_task model. No-op when there are no shard 

3407 # subscriptions to reconcile. 

3408 if not self.shard_channels: 

3409 return 

3410 # Serialize lazy executor creation and submission against concurrent 

3411 # on_slots_changed calls (EventDispatcher releases its lock before 

3412 # invoking listeners, so two MovedError-handling threads can land 

3413 # here at once) and against reset() which tears the executor down. 

3414 # Without this, two threads could each create a ThreadPoolExecutor 

3415 # and one would be orphaned (leaking its worker thread); a reset() 

3416 # interleaved between create and submit() could also raise 

3417 # RuntimeError("cannot schedule new futures after shutdown"). 

3418 with self._shard_state_lock: 

3419 if self._reconcile_executor is None: 

3420 self._reconcile_executor = ThreadPoolExecutor( 

3421 max_workers=1, 

3422 thread_name_prefix="redis-cluster-pubsub-reconcile", 

3423 ) 

3424 future = self._reconcile_executor.submit( 

3425 self.reinitialize_shard_subscriptions 

3426 ) 

3427 self._reconcile_futures.add(future) 

3428 future.add_done_callback(self._discard_reconcile_future) 

3429 # Consume the future's exception (if any) so it is not silently lost. 

3430 # reinitialize_shard_subscriptions surfaces SlotNotCoveredError when 

3431 # a slot is still transiently uncovered; route it through the same 

3432 # logger channel as the async path for consistent observability. 

3433 future.add_done_callback(self._log_reconcile_future_exception) 

3434 

3435 def _discard_reconcile_future(self, future: "Future") -> None: 

3436 # Done-callback fires on the worker thread. Take _shard_state_lock so 

3437 # the discard observes the same mutual-exclusion discipline as the 

3438 # add() / clear() sites; without it the set mutation is correct only 

3439 # because of CPython's GIL and would race under free-threaded builds. 

3440 with self._shard_state_lock: 

3441 self._reconcile_futures.discard(future) 

3442 

3443 @staticmethod 

3444 def _log_reconcile_future_exception(future: "Future") -> None: 

3445 if future.cancelled(): 

3446 return 

3447 exc = future.exception() 

3448 if exc is not None: 

3449 logger.error( 

3450 "shard subscription reconciliation failed: %r", exc, exc_info=exc 

3451 ) 

3452 

3453 def reset(self) -> None: 

3454 # Hold _shard_state_lock across the entire teardown so it observes 

3455 # the same mutual-exclusion discipline as ssubscribe / sunsubscribe / 

3456 # get_sharded_message / reinitialize_shard_subscriptions, which all 

3457 # mutate shard_channels, _shard_channel_to_node, and 

3458 # node_pubsub_mapping under this lock. Without it, super().reset() 

3459 # rebinds shard_channels and pending_unsubscribe_shard_channels in 

3460 # parallel with a concurrent user-thread mutation, silently dropping 

3461 # subscription intent. cancel_futures drops queued reconciliation 

3462 # work; the currently-running task (if any) is already serialized 

3463 # against us by this same lock - shutdown(wait=False) avoids waiting 

3464 # on the worker thread's join, not on its critical section. 

3465 with self._shard_state_lock: 

3466 if self._reconcile_executor is not None: 

3467 self._reconcile_executor.shutdown(wait=False, cancel_futures=True) 

3468 self._reconcile_executor = None 

3469 self._reconcile_futures.clear() 

3470 # Tear down per-node pubsubs (parity with async aclose) so they 

3471 # don't leak their dedicated connections and don't replay stale 

3472 # shard_channels via PubSub.on_connect on a subsequent reconnect. 

3473 # Errors are swallowed because reset() is also a fallback path 

3474 # from __del__; we cannot let one buggy per-node pubsub mask the 

3475 # rest of the teardown. 

3476 for pubsub in self.node_pubsub_mapping.values(): 

3477 try: 

3478 pubsub.reset() 

3479 except Exception: 

3480 pass 

3481 # Drop the now-dead per-node pubsubs from the mapping so the 

3482 # round-robin in _pubsubs_generator / _sharded_message_generator 

3483 # cannot yield them between teardown and re-subscription. 

3484 self.node_pubsub_mapping.clear() 

3485 # _pubsubs_generator captures node_pubsub_mapping.values() into 

3486 # a local list inside ``yield from``; clearing the mapping does 

3487 # not reach references already held by that captured snapshot, 

3488 # so a generator suspended mid-yield-from would still surface 

3489 # the now-reset() per-node pubsubs after re-subscription. 

3490 # Recreate it to drop the captured list. type(self) bypasses 

3491 # the instance-level self-shadow established at __init__ 

3492 # (self._pubsubs_generator = self._pubsubs_generator()). 

3493 self._pubsubs_generator = type(self)._pubsubs_generator(self) 

3494 super().reset() 

3495 self._shard_channel_to_node = {} 

3496 

3497 def get_redis_connection(self): 

3498 """ 

3499 Get the Redis connection of the pubsub connected node. 

3500 """ 

3501 if self.node is not None: 

3502 return self.node.redis_connection 

3503 

3504 def disconnect(self): 

3505 """ 

3506 Disconnect the pubsub connection. 

3507 """ 

3508 if self.connection: 

3509 self.connection.disconnect() 

3510 for pubsub in self.node_pubsub_mapping.values(): 

3511 if pubsub.connection: 

3512 pubsub.connection.disconnect() 

3513 

3514 

3515class ClusterPipeline(RedisCluster): 

3516 """ 

3517 Support for Redis pipeline 

3518 in cluster mode 

3519 """ 

3520 

3521 ERRORS_ALLOW_RETRY = ( 

3522 ConnectionError, 

3523 TimeoutError, 

3524 MovedError, 

3525 AskError, 

3526 TryAgainError, 

3527 ) 

3528 

3529 NO_SLOTS_COMMANDS = {"UNWATCH"} 

3530 IMMEDIATE_EXECUTE_COMMANDS = {"WATCH", "UNWATCH"} 

3531 UNWATCH_COMMANDS = {"DISCARD", "EXEC", "UNWATCH"} 

3532 

3533 @deprecated_args( 

3534 args_to_warn=[ 

3535 "cluster_error_retry_attempts", 

3536 ], 

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

3538 version="6.0.0", 

3539 ) 

3540 def __init__( 

3541 self, 

3542 nodes_manager: "NodesManager", 

3543 commands_parser: "CommandsParser", 

3544 result_callbacks: Optional[Dict[str, Callable]] = None, 

3545 cluster_response_callbacks: Optional[Dict[str, Callable]] = None, 

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

3547 read_from_replicas: bool = False, 

3548 load_balancing_strategy: Optional[LoadBalancingStrategy] = None, 

3549 cluster_error_retry_attempts: int = DEFAULT_RETRY_COUNT, 

3550 reinitialize_steps: int = 5, 

3551 retry: Optional[Retry] = None, 

3552 lock=None, 

3553 transaction=False, 

3554 policy_resolver: PolicyResolver = StaticPolicyResolver(), 

3555 event_dispatcher: Optional["EventDispatcher"] = None, 

3556 **kwargs, 

3557 ): 

3558 """ """ 

3559 self.command_stack = [] 

3560 self.nodes_manager = nodes_manager 

3561 # Share the parent cluster's HIMPORT registry (held on the NodesManager and 

3562 # referenced by every node pool). The inherited himport_prepare/discard/ 

3563 # discard_all mutate this one object, so a fieldset declared on the pipeline is 

3564 # visible to the batched himport_set pre-flight exactly as on the parent client. 

3565 self._himport_registry = nodes_manager.himport_registry 

3566 self.commands_parser = commands_parser 

3567 self.refresh_table_asap = False 

3568 self.result_callbacks = ( 

3569 result_callbacks or self.__class__.RESULT_CALLBACKS.copy() 

3570 ) 

3571 self.startup_nodes = startup_nodes if startup_nodes else [] 

3572 self.read_from_replicas = read_from_replicas 

3573 self.load_balancing_strategy = load_balancing_strategy 

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

3575 self.cluster_response_callbacks = cluster_response_callbacks 

3576 self.reinitialize_counter = 0 

3577 self.reinitialize_steps = reinitialize_steps 

3578 if retry is not None: 

3579 self.retry = retry 

3580 else: 

3581 self.retry = Retry( 

3582 backoff=ExponentialWithJitterBackoff( 

3583 base=DEFAULT_RETRY_BASE, cap=DEFAULT_RETRY_CAP 

3584 ), 

3585 retries=cluster_error_retry_attempts, 

3586 ) 

3587 

3588 self.encoder = Encoder( 

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

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

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

3592 ) 

3593 if lock is None: 

3594 lock = threading.RLock() 

3595 self._lock = lock 

3596 self.parent_execute_command = super().execute_command 

3597 self._execution_strategy: ExecutionStrategy = ( 

3598 PipelineStrategy(self) if not transaction else TransactionStrategy(self) 

3599 ) 

3600 

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

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

3603 self.__class__.RANDOM: RequestPolicy.DEFAULT_KEYLESS, 

3604 self.__class__.PRIMARIES: RequestPolicy.ALL_SHARDS, 

3605 self.__class__.ALL_NODES: RequestPolicy.ALL_NODES, 

3606 self.__class__.REPLICAS: RequestPolicy.ALL_REPLICAS, 

3607 self.__class__.DEFAULT_NODE: RequestPolicy.DEFAULT_NODE, 

3608 SLOT_ID: RequestPolicy.DEFAULT_KEYED, 

3609 } 

3610 

3611 self._policies_callback_mapping: dict[ 

3612 Union[RequestPolicy, ResponsePolicy], Callable 

3613 ] = { 

3614 RequestPolicy.DEFAULT_KEYLESS: lambda command_name: [ 

3615 self.get_random_primary_or_all_nodes(command_name) 

3616 ], 

3617 RequestPolicy.DEFAULT_KEYED: lambda command, 

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

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

3620 RequestPolicy.ALL_SHARDS: self.get_primaries, 

3621 RequestPolicy.ALL_NODES: self.get_nodes, 

3622 RequestPolicy.ALL_REPLICAS: self.get_replicas, 

3623 RequestPolicy.MULTI_SHARD: lambda *args, 

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

3625 RequestPolicy.SPECIAL: self.get_special_nodes, 

3626 ResponsePolicy.DEFAULT_KEYLESS: lambda res: res, 

3627 ResponsePolicy.DEFAULT_KEYED: lambda res: res, 

3628 } 

3629 

3630 self._policy_resolver = policy_resolver 

3631 

3632 if event_dispatcher is None: 

3633 self._event_dispatcher = EventDispatcher() 

3634 else: 

3635 self._event_dispatcher = event_dispatcher 

3636 

3637 def __repr__(self): 

3638 """ """ 

3639 return f"{type(self).__name__}" 

3640 

3641 def __enter__(self): 

3642 """ """ 

3643 return self 

3644 

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

3646 """ """ 

3647 self.reset() 

3648 

3649 def __del__(self): 

3650 try: 

3651 self.reset() 

3652 except Exception: 

3653 pass 

3654 

3655 def __len__(self): 

3656 """ """ 

3657 return len(self._execution_strategy.command_queue) 

3658 

3659 def __bool__(self): 

3660 "Pipeline instances should always evaluate to True on Python 3+" 

3661 return True 

3662 

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

3664 """ 

3665 Wrapper function for pipeline_execute_command 

3666 """ 

3667 return self._execution_strategy.execute_command(*args, **kwargs) 

3668 

3669 def pipeline_execute_command(self, *args, **options): 

3670 """ 

3671 Stage a command to be executed when execute() is next called 

3672 

3673 Returns the current Pipeline object back so commands can be 

3674 chained together, such as: 

3675 

3676 pipe = pipe.set('foo', 'bar').incr('baz').decr('bang') 

3677 

3678 At some other point, you can then run: pipe.execute(), 

3679 which will execute all commands queued in the pipe. 

3680 """ 

3681 return self._execution_strategy.execute_command(*args, **options) 

3682 

3683 def annotate_exception(self, exception, number, command): 

3684 """ 

3685 Provides extra context to the exception prior to it being handled 

3686 """ 

3687 self._execution_strategy.annotate_exception(exception, number, command) 

3688 

3689 def execute(self, raise_on_error: bool = True) -> List[Any]: 

3690 """ 

3691 Execute all the commands in the current pipeline 

3692 """ 

3693 

3694 try: 

3695 return self._execution_strategy.execute(raise_on_error) 

3696 finally: 

3697 self.reset() 

3698 

3699 def reset(self): 

3700 """ 

3701 Reset back to empty pipeline. 

3702 """ 

3703 self._execution_strategy.reset() 

3704 

3705 def send_cluster_commands( 

3706 self, stack, raise_on_error=True, allow_redirections=True 

3707 ): 

3708 return self._execution_strategy.send_cluster_commands( 

3709 stack, raise_on_error=raise_on_error, allow_redirections=allow_redirections 

3710 ) 

3711 

3712 def exists(self, *keys): 

3713 return self._execution_strategy.exists(*keys) 

3714 

3715 def eval(self): 

3716 """ """ 

3717 return self._execution_strategy.eval() 

3718 

3719 def multi(self): 

3720 """ 

3721 Start a transactional block of the pipeline after WATCH commands 

3722 are issued. End the transactional block with `execute`. 

3723 """ 

3724 self._execution_strategy.multi() 

3725 

3726 def load_scripts(self): 

3727 """ """ 

3728 self._execution_strategy.load_scripts() 

3729 

3730 def discard(self): 

3731 """ """ 

3732 self._execution_strategy.discard() 

3733 

3734 def watch(self, *names): 

3735 """Watches the values at keys ``names``""" 

3736 self._execution_strategy.watch(*names) 

3737 

3738 def unwatch(self): 

3739 """Unwatches all previously specified keys""" 

3740 self._execution_strategy.unwatch() 

3741 

3742 def script_load_for_pipeline(self, *args, **kwargs): 

3743 self._execution_strategy.script_load_for_pipeline(*args, **kwargs) 

3744 

3745 def delete(self, *names): 

3746 self._execution_strategy.delete(*names) 

3747 

3748 def unlink(self, *names): 

3749 self._execution_strategy.unlink(*names) 

3750 

3751 

3752def block_pipeline_command(name: str) -> Callable[..., Any]: 

3753 """ 

3754 Prints error because some pipelined commands should 

3755 be blocked when running in cluster-mode 

3756 """ 

3757 

3758 def inner(*args, **kwargs): 

3759 raise RedisClusterException( 

3760 f"ERROR: Calling pipelined function {name} is blocked " 

3761 f"when running redis in cluster mode..." 

3762 ) 

3763 

3764 return inner 

3765 

3766 

3767# Blocked pipeline commands 

3768PIPELINE_BLOCKED_COMMANDS = ( 

3769 "BGREWRITEAOF", 

3770 "BGSAVE", 

3771 "BITOP", 

3772 "BRPOPLPUSH", 

3773 "CLIENT GETNAME", 

3774 "CLIENT KILL", 

3775 "CLIENT LIST", 

3776 "CLIENT SETNAME", 

3777 "CLIENT", 

3778 "CONFIG GET", 

3779 "CONFIG RESETSTAT", 

3780 "CONFIG REWRITE", 

3781 "CONFIG SET", 

3782 "CONFIG", 

3783 "DBSIZE", 

3784 "ECHO", 

3785 "EVALSHA", 

3786 "FLUSHALL", 

3787 "FLUSHDB", 

3788 "INFO", 

3789 "KEYS", 

3790 "LASTSAVE", 

3791 "MGET", 

3792 "MGET NONATOMIC", 

3793 "MOVE", 

3794 "MSET", 

3795 "MSETEX", 

3796 "MSET NONATOMIC", 

3797 "MSETNX", 

3798 "PFCOUNT", 

3799 "PFMERGE", 

3800 "PING", 

3801 "PUBLISH", 

3802 "RANDOMKEY", 

3803 "READONLY", 

3804 "READWRITE", 

3805 "RENAME", 

3806 "RENAMENX", 

3807 "RPOPLPUSH", 

3808 "SAVE", 

3809 "SCAN", 

3810 "SCRIPT EXISTS", 

3811 "SCRIPT FLUSH", 

3812 "SCRIPT KILL", 

3813 "SCRIPT LOAD", 

3814 "SCRIPT", 

3815 "SDIFF", 

3816 "SDIFFSTORE", 

3817 "SENTINEL GET MASTER ADDR BY NAME", 

3818 "SENTINEL MASTER", 

3819 "SENTINEL MASTERS", 

3820 "SENTINEL MONITOR", 

3821 "SENTINEL REMOVE", 

3822 "SENTINEL SENTINELS", 

3823 "SENTINEL SET", 

3824 "SENTINEL SLAVES", 

3825 "SENTINEL", 

3826 "SHUTDOWN", 

3827 "SINTER", 

3828 "SINTERSTORE", 

3829 "SLAVEOF", 

3830 "SLOWLOG GET", 

3831 "SLOWLOG LEN", 

3832 "SLOWLOG RESET", 

3833 "SLOWLOG", 

3834 "SMOVE", 

3835 "SORT", 

3836 "SUNION", 

3837 "SUNIONSTORE", 

3838 "TIME", 

3839) 

3840for command in PIPELINE_BLOCKED_COMMANDS: 

3841 command = command.replace(" ", "_").lower() 

3842 

3843 setattr(ClusterPipeline, command, block_pipeline_command(command)) 

3844 

3845 

3846class PipelineCommand: 

3847 """ """ 

3848 

3849 def __init__(self, args, options=None, position=None): 

3850 self.args = args 

3851 if options is None: 

3852 options = {} 

3853 self.options = options 

3854 self.position = position 

3855 self.result = None 

3856 self.node = None 

3857 self.asking = False 

3858 self.command_policies: Optional[CommandPolicies] = None 

3859 

3860 

3861class NodeCommands: 

3862 """ """ 

3863 

3864 def __init__( 

3865 self, parse_response, connection_pool: ConnectionPool, connection: Connection 

3866 ): 

3867 """ """ 

3868 self.parse_response = parse_response 

3869 self.connection_pool = connection_pool 

3870 self.connection = connection 

3871 self.commands = [] 

3872 

3873 def append(self, c): 

3874 """ """ 

3875 self.commands.append(c) 

3876 

3877 def write(self): 

3878 """ 

3879 Code borrowed from Redis so it can be fixed 

3880 """ 

3881 connection = self.connection 

3882 commands = self.commands 

3883 

3884 # We are going to clobber the commands with the write, so go ahead 

3885 # and ensure that nothing is sitting there from a previous run. 

3886 for c in commands: 

3887 c.result = None 

3888 

3889 # build up all commands into a single request to increase network perf 

3890 # send all the commands and catch connection and timeout errors. 

3891 try: 

3892 connection.send_packed_command( 

3893 connection.pack_commands([c.args for c in commands]) 

3894 ) 

3895 except (ConnectionError, TimeoutError) as e: 

3896 for c in commands: 

3897 c.result = e 

3898 

3899 def read(self): 

3900 """ """ 

3901 connection = self.connection 

3902 for c in self.commands: 

3903 # if there is a result on this command, 

3904 # it means we ran into an exception 

3905 # like a connection error. Trying to parse 

3906 # a response on a connection that 

3907 # is no longer open will result in a 

3908 # connection error raised by redis-py. 

3909 # but redis-py doesn't check in parse_response 

3910 # that the sock object is 

3911 # still set and if you try to 

3912 # read from a closed connection, it will 

3913 # result in an AttributeError because 

3914 # it will do a readline() call on None. 

3915 # This can have all kinds of nasty side-effects. 

3916 # Treating this case as a connection error 

3917 # is fine because it will dump 

3918 # the connection object back into the 

3919 # pool and on the next write, it will 

3920 # explicitly open the connection and all will be well. 

3921 if c.result is None: 

3922 try: 

3923 c.result = self.parse_response(connection, c.args[0], **c.options) 

3924 except (ConnectionError, TimeoutError) as e: 

3925 for c in self.commands: 

3926 c.result = e 

3927 return 

3928 except RedisError: 

3929 c.result = sys.exc_info()[1] 

3930 

3931 

3932class ExecutionStrategy(ABC): 

3933 @property 

3934 @abstractmethod 

3935 def command_queue(self): 

3936 pass 

3937 

3938 @abstractmethod 

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

3940 """ 

3941 Execution flow for current execution strategy. 

3942 

3943 See: ClusterPipeline.execute_command() 

3944 """ 

3945 pass 

3946 

3947 @abstractmethod 

3948 def annotate_exception(self, exception, number, command): 

3949 """ 

3950 Annotate exception according to current execution strategy. 

3951 

3952 See: ClusterPipeline.annotate_exception() 

3953 """ 

3954 pass 

3955 

3956 @abstractmethod 

3957 def pipeline_execute_command(self, *args, **options): 

3958 """ 

3959 Pipeline execution flow for current execution strategy. 

3960 

3961 See: ClusterPipeline.pipeline_execute_command() 

3962 """ 

3963 pass 

3964 

3965 @abstractmethod 

3966 def execute(self, raise_on_error: bool = True) -> List[Any]: 

3967 """ 

3968 Executes current execution strategy. 

3969 

3970 See: ClusterPipeline.execute() 

3971 """ 

3972 pass 

3973 

3974 @abstractmethod 

3975 def send_cluster_commands( 

3976 self, stack, raise_on_error=True, allow_redirections=True 

3977 ): 

3978 """ 

3979 Sends commands according to current execution strategy. 

3980 

3981 See: ClusterPipeline.send_cluster_commands() 

3982 """ 

3983 pass 

3984 

3985 @abstractmethod 

3986 def reset(self): 

3987 """ 

3988 Resets current execution strategy. 

3989 

3990 See: ClusterPipeline.reset() 

3991 """ 

3992 pass 

3993 

3994 @abstractmethod 

3995 def exists(self, *keys): 

3996 pass 

3997 

3998 @abstractmethod 

3999 def eval(self): 

4000 pass 

4001 

4002 @abstractmethod 

4003 def multi(self): 

4004 """ 

4005 Starts transactional context. 

4006 

4007 See: ClusterPipeline.multi() 

4008 """ 

4009 pass 

4010 

4011 @abstractmethod 

4012 def load_scripts(self): 

4013 pass 

4014 

4015 @abstractmethod 

4016 def watch(self, *names): 

4017 pass 

4018 

4019 @abstractmethod 

4020 def unwatch(self): 

4021 """ 

4022 Unwatches all previously specified keys 

4023 

4024 See: ClusterPipeline.unwatch() 

4025 """ 

4026 pass 

4027 

4028 @abstractmethod 

4029 def script_load_for_pipeline(self, *args, **kwargs): 

4030 pass 

4031 

4032 @abstractmethod 

4033 def delete(self, *names): 

4034 """ 

4035 "Delete a key specified by ``names``" 

4036 

4037 See: ClusterPipeline.delete() 

4038 """ 

4039 pass 

4040 

4041 @abstractmethod 

4042 def unlink(self, *names): 

4043 """ 

4044 "Unlink a key specified by ``names``" 

4045 

4046 See: ClusterPipeline.unlink() 

4047 """ 

4048 pass 

4049 

4050 @abstractmethod 

4051 def discard(self): 

4052 pass 

4053 

4054 

4055class AbstractStrategy(ExecutionStrategy): 

4056 def __init__( 

4057 self, 

4058 pipe: ClusterPipeline, 

4059 ): 

4060 self._command_queue: List[PipelineCommand] = [] 

4061 self._pipe = pipe 

4062 self._nodes_manager = self._pipe.nodes_manager 

4063 

4064 @property 

4065 def command_queue(self): 

4066 return self._command_queue 

4067 

4068 @command_queue.setter 

4069 def command_queue(self, queue: List[PipelineCommand]): 

4070 self._command_queue = queue 

4071 

4072 @abstractmethod 

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

4074 pass 

4075 

4076 def pipeline_execute_command(self, *args, **options): 

4077 self._command_queue.append( 

4078 PipelineCommand(args, options, len(self._command_queue)) 

4079 ) 

4080 return self._pipe 

4081 

4082 def _himport_prepare_pipeline(self, redis_node, conn, commands): 

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

4084 _himport_exec.prepare_pipeline(redis_node, conn, [args for args, _ in commands]) 

4085 

4086 @abstractmethod 

4087 def execute(self, raise_on_error: bool = True) -> List[Any]: 

4088 pass 

4089 

4090 @abstractmethod 

4091 def send_cluster_commands( 

4092 self, stack, raise_on_error=True, allow_redirections=True 

4093 ): 

4094 pass 

4095 

4096 @abstractmethod 

4097 def reset(self): 

4098 pass 

4099 

4100 def exists(self, *keys): 

4101 return self.execute_command("EXISTS", *keys) 

4102 

4103 def eval(self): 

4104 """ """ 

4105 raise RedisClusterException("method eval() is not implemented") 

4106 

4107 def load_scripts(self): 

4108 """ """ 

4109 raise RedisClusterException("method load_scripts() is not implemented") 

4110 

4111 def script_load_for_pipeline(self, *args, **kwargs): 

4112 """ """ 

4113 raise RedisClusterException( 

4114 "method script_load_for_pipeline() is not implemented" 

4115 ) 

4116 

4117 def annotate_exception(self, exception, number, command): 

4118 """ 

4119 Provides extra context to the exception prior to it being handled 

4120 """ 

4121 cmd = " ".join(map(safe_str, command)) 

4122 msg = ( 

4123 f"Command # {number} ({truncate_text(cmd)}) of pipeline " 

4124 f"caused error: {exception.args[0]}" 

4125 ) 

4126 exception.args = (msg,) + exception.args[1:] 

4127 

4128 

4129class PipelineStrategy(AbstractStrategy): 

4130 def __init__(self, pipe: ClusterPipeline): 

4131 super().__init__(pipe) 

4132 self.command_flags = pipe.command_flags 

4133 

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

4135 return self.pipeline_execute_command(*args, **kwargs) 

4136 

4137 def _raise_first_error(self, stack, start_time): 

4138 """ 

4139 Raise the first exception on the stack 

4140 """ 

4141 for c in stack: 

4142 r = c.result 

4143 if isinstance(r, Exception): 

4144 self.annotate_exception(r, c.position + 1, c.args) 

4145 

4146 record_operation_duration( 

4147 command_name="PIPELINE", 

4148 duration_seconds=time.monotonic() - start_time, 

4149 error=r, 

4150 ) 

4151 

4152 raise r 

4153 

4154 def execute(self, raise_on_error: bool = True) -> List[Any]: 

4155 stack = self._command_queue 

4156 if not stack: 

4157 return [] 

4158 

4159 try: 

4160 return self.send_cluster_commands(stack, raise_on_error) 

4161 finally: 

4162 self.reset() 

4163 

4164 def reset(self): 

4165 """ 

4166 Reset back to empty pipeline. 

4167 """ 

4168 self._command_queue = [] 

4169 

4170 def send_cluster_commands( 

4171 self, stack, raise_on_error=True, allow_redirections=True 

4172 ): 

4173 """ 

4174 Wrapper for RedisCluster.ERRORS_ALLOW_RETRY errors handling. 

4175 

4176 If one of the retryable exceptions has been thrown we assume that: 

4177 - connection_pool was disconnected 

4178 - connection_pool was reset 

4179 - refresh_table_asap set to True 

4180 

4181 It will try the number of times specified by 

4182 the retries in config option "self.retry" 

4183 which defaults to 10 unless manually configured. 

4184 

4185 If it reaches the number of times, the command will 

4186 raises ClusterDownException. 

4187 """ 

4188 if not stack: 

4189 return [] 

4190 retry_attempts = self._pipe.retry.get_retries() 

4191 while True: 

4192 try: 

4193 return self._send_cluster_commands( 

4194 stack, 

4195 raise_on_error=raise_on_error, 

4196 allow_redirections=allow_redirections, 

4197 ) 

4198 except RedisCluster.ERRORS_ALLOW_RETRY as e: 

4199 if retry_attempts > 0: 

4200 # Try again with the new cluster setup. All other errors 

4201 # should be raised. 

4202 retry_attempts -= 1 

4203 pass 

4204 else: 

4205 raise e 

4206 

4207 def _send_cluster_commands( 

4208 self, stack, raise_on_error=True, allow_redirections=True 

4209 ): 

4210 """ 

4211 Send a bunch of cluster commands to the redis cluster. 

4212 

4213 `allow_redirections` If the pipeline should follow 

4214 `ASK` & `MOVED` responses automatically. If set 

4215 to false it will raise RedisClusterException. 

4216 """ 

4217 # the first time sending the commands we send all of 

4218 # the commands that were queued up. 

4219 # if we have to run through it again, we only retry 

4220 # the commands that failed. 

4221 attempt = sorted(stack, key=lambda x: x.position) 

4222 is_default_node = False 

4223 # build a list of node objects based on node names we need to 

4224 nodes: dict[str, NodeCommands] = {} 

4225 # node objects keyed by name, so each node's connection can be pre-flighted 

4226 # for HIMPORT SET (PREPARE) before the batched write. 

4227 node_objs: dict = {} 

4228 nodes_written = 0 

4229 nodes_read = 0 

4230 

4231 try: 

4232 # as we move through each command that still needs to be processed, 

4233 # we figure out the slot number that command maps to, then from 

4234 # the slot determine the node. 

4235 for c in attempt: 

4236 command_policies = self._pipe._policy_resolver.resolve( 

4237 c.args[0].lower() 

4238 ) 

4239 # refer to our internal node -> slot table that 

4240 # tells us where a given command should route to. 

4241 # (it might be possible we have a cached node that no longer 

4242 # exists in the cluster, which is why we do this in a loop) 

4243 passed_targets = c.options.pop("target_nodes", None) 

4244 if passed_targets and not self._is_nodes_flag(passed_targets): 

4245 target_nodes = self._parse_target_nodes(passed_targets) 

4246 

4247 if not command_policies: 

4248 command_policies = CommandPolicies() 

4249 else: 

4250 if not command_policies: 

4251 command = c.args[0].upper() 

4252 if ( 

4253 len(c.args) >= 2 

4254 and f"{c.args[0]} {c.args[1]}".upper() 

4255 in self._pipe.command_flags 

4256 ): 

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

4258 

4259 # We only could resolve key properties if command is not 

4260 # in a list of pre-defined request policies 

4261 command_flag = self.command_flags.get(command) 

4262 if not command_flag: 

4263 # Fallback to default policy 

4264 if not self._pipe.get_default_node(): 

4265 keys = None 

4266 else: 

4267 keys = self._pipe._get_command_keys(*c.args) 

4268 if not keys or len(keys) == 0: 

4269 command_policies = CommandPolicies() 

4270 else: 

4271 command_policies = CommandPolicies( 

4272 request_policy=RequestPolicy.DEFAULT_KEYED, 

4273 response_policy=ResponsePolicy.DEFAULT_KEYED, 

4274 ) 

4275 else: 

4276 if command_flag in self._pipe._command_flags_mapping: 

4277 command_policies = CommandPolicies( 

4278 request_policy=self._pipe._command_flags_mapping[ 

4279 command_flag 

4280 ] 

4281 ) 

4282 else: 

4283 command_policies = CommandPolicies() 

4284 

4285 target_nodes = self._determine_nodes( 

4286 *c.args, 

4287 request_policy=command_policies.request_policy, 

4288 node_flag=passed_targets, 

4289 ) 

4290 if not target_nodes: 

4291 raise RedisClusterException( 

4292 f"No targets were found to execute {c.args} command on" 

4293 ) 

4294 c.command_policies = command_policies 

4295 if len(target_nodes) > 1: 

4296 raise RedisClusterException( 

4297 f"Too many targets for command {c.args}" 

4298 ) 

4299 

4300 node = target_nodes[0] 

4301 if node == self._pipe.get_default_node(): 

4302 is_default_node = True 

4303 

4304 # now that we know the name of the node 

4305 # ( it's just a string in the form of host:port ) 

4306 # we can build a list of commands for each node. 

4307 node_name = node.name 

4308 if node_name not in nodes: 

4309 redis_node = self._pipe.get_redis_connection(node) 

4310 try: 

4311 connection = get_connection(redis_node) 

4312 except (ConnectionError, TimeoutError): 

4313 # Release any connections we've already acquired before clearing nodes 

4314 for n in nodes.values(): 

4315 n.connection_pool.release(n.connection) 

4316 # Connection retries are being handled in the node's 

4317 # Retry object. Reinitialize the node -> slot table. 

4318 self._nodes_manager.initialize() 

4319 if is_default_node: 

4320 self._pipe.replace_default_node() 

4321 nodes = {} 

4322 raise 

4323 nodes[node_name] = NodeCommands( 

4324 redis_node.parse_response, 

4325 redis_node.connection_pool, 

4326 connection, 

4327 ) 

4328 node_objs[node_name] = node 

4329 nodes[node_name].append(c) 

4330 

4331 # send the commands in sequence. 

4332 # we write to all the open sockets for each node first, 

4333 # before reading anything 

4334 # this allows us to flush all the requests out across the 

4335 # network 

4336 # so that we can read them from different sockets as they come back. 

4337 # we don't multiplex on the sockets as they come available, 

4338 # but that shouldn't make too much difference. 

4339 

4340 # HIMPORT SETs in the batch need their fieldsets prepared on each 

4341 # node's connection first; the packed write bypasses the per-command 

4342 # lazy prepare, so pre-flight the PREPARE (once per node) here. 

4343 for node_name, n in nodes.items(): 

4344 redis_node = self._pipe.get_redis_connection(node_objs[node_name]) 

4345 self._himport_prepare_pipeline( 

4346 redis_node, n.connection, [(c.args, c.options) for c in n.commands] 

4347 ) 

4348 

4349 # Start timing for observability 

4350 start_time = time.monotonic() 

4351 

4352 node_commands = nodes.values() 

4353 for n in node_commands: 

4354 nodes_written += 1 

4355 n.write() 

4356 

4357 for n in node_commands: 

4358 n.read() 

4359 

4360 # Find the first error in this node's commands, if any 

4361 node_error = None 

4362 for cmd in n.commands: 

4363 if isinstance(cmd.result, Exception): 

4364 node_error = cmd.result 

4365 break 

4366 

4367 record_operation_duration( 

4368 command_name="PIPELINE", 

4369 duration_seconds=time.monotonic() - start_time, 

4370 server_address=n.connection.host, 

4371 server_port=n.connection.port, 

4372 db_namespace=str(n.connection.db), 

4373 error=node_error, 

4374 ) 

4375 nodes_read += 1 

4376 finally: 

4377 # release all the redis connections we allocated earlier 

4378 # back into the connection pool. 

4379 # if the connection is dirty (that is: we've written 

4380 # commands to it, but haven't read the responses), we need 

4381 # to close the connection before returning it to the pool. 

4382 # otherwise, the next caller to use this connection will 

4383 # read the response from _this_ request, not its own request. 

4384 # disconnecting discards the dirty state & forces the next 

4385 # caller to reconnect. 

4386 # NOTE: dicts have a consistent ordering; we're iterating 

4387 # through nodes.values() in the same order as we are when 

4388 # reading / writing to the connections above, which is critical 

4389 # for how we're using the nodes_written/nodes_read offsets. 

4390 for i, n in enumerate(nodes.values()): 

4391 if i < nodes_written and i >= nodes_read: 

4392 n.connection.disconnect() 

4393 n.connection_pool.release(n.connection) 

4394 

4395 # if the response isn't an exception it is a 

4396 # valid response from the node 

4397 # we're all done with that command, YAY! 

4398 # if we have more commands to attempt, we've run into problems. 

4399 # collect all the commands we are allowed to retry. 

4400 # (MOVED, ASK, or connection errors or timeout errors) 

4401 attempt = sorted( 

4402 ( 

4403 c 

4404 for c in attempt 

4405 if isinstance(c.result, ClusterPipeline.ERRORS_ALLOW_RETRY) 

4406 ), 

4407 key=lambda x: x.position, 

4408 ) 

4409 if attempt and allow_redirections: 

4410 # RETRY MAGIC HAPPENS HERE! 

4411 # send these remaining commands one at a time using `execute_command` 

4412 # in the main client. This keeps our retry logic 

4413 # in one place mostly, 

4414 # and allows us to be more confident in correctness of behavior. 

4415 # at this point any speed gains from pipelining have been lost 

4416 # anyway, so we might as well make the best 

4417 # attempt to get the correct behavior. 

4418 # 

4419 # The client command will handle retries for each 

4420 # individual command sequentially as we pass each 

4421 # one into `execute_command`. Any exceptions 

4422 # that bubble out should only appear once all 

4423 # retries have been exhausted. 

4424 # 

4425 # If a lot of commands have failed, we'll be setting the 

4426 # flag to rebuild the slots table from scratch. 

4427 # So MOVED errors should correct themselves fairly quickly. 

4428 self._pipe.reinitialize_counter += 1 

4429 if self._pipe._should_reinitialized(): 

4430 self._nodes_manager.initialize() 

4431 if is_default_node: 

4432 self._pipe.replace_default_node() 

4433 for c in attempt: 

4434 try: 

4435 # send each command individually like we 

4436 # do in the main client. 

4437 c.result = self._pipe.parent_execute_command(*c.args, **c.options) 

4438 except RedisError as e: 

4439 c.result = e 

4440 

4441 # turn the response back into a simple flat array that corresponds 

4442 # to the sequence of commands issued in the stack in pipeline.execute() 

4443 response = [] 

4444 for c in sorted(stack, key=lambda x: x.position): 

4445 if c.args[0] in self._pipe.cluster_response_callbacks: 

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

4447 c.options.pop("keys", None) 

4448 c.result = self._pipe._policies_callback_mapping[ 

4449 c.command_policies.response_policy 

4450 ]( 

4451 self._pipe.cluster_response_callbacks[c.args[0]]( 

4452 c.result, **c.options 

4453 ) 

4454 ) 

4455 response.append(c.result) 

4456 

4457 if raise_on_error: 

4458 self._raise_first_error(stack, start_time) 

4459 

4460 return response 

4461 

4462 def _is_nodes_flag(self, target_nodes): 

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

4464 

4465 def _parse_target_nodes(self, target_nodes): 

4466 if isinstance(target_nodes, list): 

4467 nodes = target_nodes 

4468 elif isinstance(target_nodes, ClusterNode): 

4469 # Supports passing a single ClusterNode as a variable 

4470 nodes = [target_nodes] 

4471 elif isinstance(target_nodes, dict): 

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

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

4474 # rc.cluster_save_config(rc.get_primaries()) 

4475 nodes = target_nodes.values() 

4476 else: 

4477 raise TypeError( 

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

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

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

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

4482 ) 

4483 return nodes 

4484 

4485 def _determine_nodes( 

4486 self, *args, request_policy: RequestPolicy, **kwargs 

4487 ) -> List["ClusterNode"]: 

4488 # Determine which nodes should be executed the command on. 

4489 # Returns a list of target nodes. 

4490 command = args[0].upper() 

4491 if ( 

4492 len(args) >= 2 

4493 and f"{args[0]} {args[1]}".upper() in self._pipe.command_flags 

4494 ): 

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

4496 

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

4498 if nodes_flag is not None: 

4499 # nodes flag passed by the user 

4500 command_flag = nodes_flag 

4501 else: 

4502 # get the nodes group for this command if it was predefined 

4503 command_flag = self._pipe.command_flags.get(command) 

4504 

4505 if command_flag in self._pipe._command_flags_mapping: 

4506 request_policy = self._pipe._command_flags_mapping[command_flag] 

4507 

4508 policy_callback = self._pipe._policies_callback_mapping[request_policy] 

4509 

4510 if request_policy == RequestPolicy.DEFAULT_KEYED: 

4511 nodes = policy_callback(command, *args) 

4512 elif request_policy == RequestPolicy.MULTI_SHARD: 

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

4514 elif request_policy == RequestPolicy.DEFAULT_KEYLESS: 

4515 nodes = policy_callback(args[0]) 

4516 else: 

4517 nodes = policy_callback() 

4518 

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

4520 self._aggregate_nodes = nodes 

4521 

4522 return nodes 

4523 

4524 def multi(self): 

4525 raise RedisClusterException( 

4526 "method multi() is not supported outside of transactional context" 

4527 ) 

4528 

4529 def discard(self): 

4530 raise RedisClusterException( 

4531 "method discard() is not supported outside of transactional context" 

4532 ) 

4533 

4534 def watch(self, *names): 

4535 raise RedisClusterException( 

4536 "method watch() is not supported outside of transactional context" 

4537 ) 

4538 

4539 def unwatch(self, *names): 

4540 raise RedisClusterException( 

4541 "method unwatch() is not supported outside of transactional context" 

4542 ) 

4543 

4544 def delete(self, *names): 

4545 if len(names) != 1: 

4546 raise RedisClusterException( 

4547 "deleting multiple keys is not implemented in pipeline command" 

4548 ) 

4549 

4550 return self.execute_command("DEL", names[0]) 

4551 

4552 def unlink(self, *names): 

4553 if len(names) != 1: 

4554 raise RedisClusterException( 

4555 "unlinking multiple keys is not implemented in pipeline command" 

4556 ) 

4557 

4558 return self.execute_command("UNLINK", names[0]) 

4559 

4560 

4561class TransactionStrategy(AbstractStrategy): 

4562 NO_SLOTS_COMMANDS = {"UNWATCH"} 

4563 IMMEDIATE_EXECUTE_COMMANDS = {"WATCH", "UNWATCH"} 

4564 UNWATCH_COMMANDS = {"DISCARD", "EXEC", "UNWATCH"} 

4565 SLOT_REDIRECT_ERRORS = (AskError, MovedError) 

4566 CONNECTION_ERRORS = ( 

4567 ConnectionError, 

4568 OSError, 

4569 ClusterDownError, 

4570 SlotNotCoveredError, 

4571 ) 

4572 

4573 def __init__(self, pipe: ClusterPipeline): 

4574 super().__init__(pipe) 

4575 self._explicit_transaction = False 

4576 self._watching = False 

4577 self._pipeline_slots: Set[int] = set() 

4578 self._transaction_connection: Optional[Connection] = None 

4579 self._executing = False 

4580 self._retry = copy(self._pipe.retry) 

4581 self._retry.update_supported_errors( 

4582 RedisCluster.ERRORS_ALLOW_RETRY + self.SLOT_REDIRECT_ERRORS 

4583 ) 

4584 

4585 def _get_client_and_connection_for_transaction(self) -> Tuple[Redis, Connection]: 

4586 """ 

4587 Find a connection for a pipeline transaction. 

4588 

4589 For running an atomic transaction, watch keys ensure that contents have not been 

4590 altered as long as the watch commands for those keys were sent over the same 

4591 connection. So once we start watching a key, we fetch a connection to the 

4592 node that owns that slot and reuse it. 

4593 """ 

4594 if not self._pipeline_slots: 

4595 raise RedisClusterException( 

4596 "At least a command with a key is needed to identify a node" 

4597 ) 

4598 

4599 node: ClusterNode = self._nodes_manager.get_node_from_slot( 

4600 list(self._pipeline_slots)[0], False 

4601 ) 

4602 redis_node: Redis = self._pipe.get_redis_connection(node) 

4603 if self._transaction_connection: 

4604 if not redis_node.connection_pool.owns_connection( 

4605 self._transaction_connection 

4606 ): 

4607 previous_node = self._nodes_manager.find_connection_owner( 

4608 self._transaction_connection 

4609 ) 

4610 previous_node.connection_pool.release(self._transaction_connection) 

4611 self._transaction_connection = None 

4612 

4613 if not self._transaction_connection: 

4614 self._transaction_connection = get_connection(redis_node) 

4615 

4616 return redis_node, self._transaction_connection 

4617 

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

4619 slot_number: Optional[int] = None 

4620 if args[0] not in ClusterPipeline.NO_SLOTS_COMMANDS: 

4621 slot_number = self._pipe.determine_slot(*args) 

4622 

4623 if ( 

4624 self._watching or args[0] in self.IMMEDIATE_EXECUTE_COMMANDS 

4625 ) and not self._explicit_transaction: 

4626 if args[0] == "WATCH": 

4627 self._validate_watch() 

4628 

4629 if slot_number is not None: 

4630 if self._pipeline_slots and slot_number not in self._pipeline_slots: 

4631 raise CrossSlotTransactionError( 

4632 "Cannot watch or send commands on different slots" 

4633 ) 

4634 

4635 self._pipeline_slots.add(slot_number) 

4636 elif args[0] not in self.NO_SLOTS_COMMANDS: 

4637 raise RedisClusterException( 

4638 f"Cannot identify slot number for command: {args[0]}," 

4639 "it cannot be triggered in a transaction" 

4640 ) 

4641 

4642 return self._immediate_execute_command(*args, **kwargs) 

4643 else: 

4644 if slot_number is not None: 

4645 self._pipeline_slots.add(slot_number) 

4646 

4647 return self.pipeline_execute_command(*args, **kwargs) 

4648 

4649 def _validate_watch(self): 

4650 if self._explicit_transaction: 

4651 raise RedisError("Cannot issue a WATCH after a MULTI") 

4652 

4653 self._watching = True 

4654 

4655 def _immediate_execute_command(self, *args, **options): 

4656 return self._retry.call_with_retry( 

4657 lambda: self._get_connection_and_send_command(*args, **options), 

4658 self._reinitialize_on_error, 

4659 with_failure_count=True, 

4660 ) 

4661 

4662 def _get_connection_and_send_command(self, *args, **options): 

4663 redis_node, connection = self._get_client_and_connection_for_transaction() 

4664 

4665 # Start timing for observability 

4666 start_time = time.monotonic() 

4667 

4668 try: 

4669 response = self._send_command_parse_response( 

4670 connection, redis_node, args[0], *args, **options 

4671 ) 

4672 

4673 record_operation_duration( 

4674 command_name=args[0], 

4675 duration_seconds=time.monotonic() - start_time, 

4676 server_address=connection.host, 

4677 server_port=connection.port, 

4678 db_namespace=str(connection.db), 

4679 ) 

4680 

4681 return response 

4682 except Exception as e: 

4683 if connection: 

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

4685 e.connection = connection 

4686 record_operation_duration( 

4687 command_name=args[0], 

4688 duration_seconds=time.monotonic() - start_time, 

4689 server_address=connection.host, 

4690 server_port=connection.port, 

4691 db_namespace=str(connection.db), 

4692 error=e, 

4693 ) 

4694 raise 

4695 

4696 def _send_command_parse_response( 

4697 self, conn, redis_node: Redis, command_name, *args, **options 

4698 ): 

4699 """ 

4700 Send a command and parse the response 

4701 """ 

4702 

4703 # HIMPORT SET's wire form depends on per-connection state: the fieldset 

4704 # must be PREPAREd on this connection first, and any fieldset discarded 

4705 # since this connection last reconciled must be dropped. The 

4706 # immediate/watched path (commands issued after WATCH, before MULTI) 

4707 # would otherwise send a bare HIMPORT SET and fail with "no such 

4708 # fieldset". Route it through the node's HIMPORT executor, the same way 

4709 # the normal cluster path, the batched MULTI/EXEC path, and standalone 

4710 # watched pipelines all do. 

4711 himport_set = parse_himport_set_args(args) 

4712 if himport_set is not None: 

4713 # HIMPORT SET in the joined or split raw form; operands at the right 

4714 # offsets. Too few operands returns None and falls through to the bare 

4715 # send so the server returns its arity error. 

4716 key, fieldset_name, values = himport_set 

4717 output = redis_node._himport_execute_set(conn, key, fieldset_name, values) 

4718 else: 

4719 conn.send_command(*args) 

4720 output = redis_node.parse_response(conn, command_name, **options) 

4721 

4722 if command_name in self.UNWATCH_COMMANDS: 

4723 self._watching = False 

4724 return output 

4725 

4726 def _reinitialize_on_error(self, error, failure_count): 

4727 if hasattr(error, "connection"): 

4728 record_error_count( 

4729 server_address=error.connection.host, 

4730 server_port=error.connection.port, 

4731 network_peer_address=error.connection.host, 

4732 network_peer_port=error.connection.port, 

4733 error_type=error, 

4734 retry_attempts=failure_count, 

4735 is_internal=True, 

4736 ) 

4737 

4738 if self._watching: 

4739 if type(error) in self.SLOT_REDIRECT_ERRORS and self._executing: 

4740 raise WatchError("Slot rebalancing occurred while watching keys") 

4741 

4742 if ( 

4743 type(error) in self.SLOT_REDIRECT_ERRORS 

4744 or type(error) in self.CONNECTION_ERRORS 

4745 ): 

4746 if self._transaction_connection: 

4747 if is_debug_log_enabled(): 

4748 logger.debug( 

4749 f"Operation failed, " 

4750 f"with connection: {self._transaction_connection}, " 

4751 f"details: {self._transaction_connection.extract_connection_details()}", 

4752 ) 

4753 # Disconnect and release back to pool 

4754 self._transaction_connection.disconnect() 

4755 node = self._nodes_manager.find_connection_owner( 

4756 self._transaction_connection 

4757 ) 

4758 if node and node.redis_connection: 

4759 node.redis_connection.connection_pool.release( 

4760 self._transaction_connection 

4761 ) 

4762 self._transaction_connection = None 

4763 

4764 self._pipe.reinitialize_counter += 1 

4765 if self._pipe._should_reinitialized(): 

4766 self._nodes_manager.initialize() 

4767 self.reinitialize_counter = 0 

4768 else: 

4769 if isinstance(error, AskError): 

4770 self._nodes_manager.move_slot(error) 

4771 

4772 self._executing = False 

4773 

4774 def _raise_first_error(self, responses, stack, start_time): 

4775 """ 

4776 Raise the first exception on the stack 

4777 """ 

4778 for r, cmd in zip(responses, stack): 

4779 if isinstance(r, Exception): 

4780 self.annotate_exception(r, cmd.position + 1, cmd.args) 

4781 

4782 record_operation_duration( 

4783 command_name="TRANSACTION", 

4784 duration_seconds=time.monotonic() - start_time, 

4785 server_address=self._transaction_connection.host, 

4786 server_port=self._transaction_connection.port, 

4787 db_namespace=str(self._transaction_connection.db), 

4788 ) 

4789 

4790 raise r 

4791 

4792 def execute(self, raise_on_error: bool = True) -> List[Any]: 

4793 stack = self._command_queue 

4794 if not stack and (not self._watching or not self._pipeline_slots): 

4795 return [] 

4796 

4797 return self._execute_transaction_with_retries(stack, raise_on_error) 

4798 

4799 def _execute_transaction_with_retries( 

4800 self, stack: List["PipelineCommand"], raise_on_error: bool 

4801 ): 

4802 return self._retry.call_with_retry( 

4803 lambda: self._execute_transaction(stack, raise_on_error), 

4804 lambda error, failure_count: self._reinitialize_on_error( 

4805 error, failure_count 

4806 ), 

4807 with_failure_count=True, 

4808 ) 

4809 

4810 def _execute_transaction( 

4811 self, stack: List["PipelineCommand"], raise_on_error: bool 

4812 ): 

4813 if len(self._pipeline_slots) > 1: 

4814 raise CrossSlotTransactionError( 

4815 "All keys involved in a cluster transaction must map to the same slot" 

4816 ) 

4817 

4818 self._executing = True 

4819 

4820 redis_node, connection = self._get_client_and_connection_for_transaction() 

4821 

4822 # Ensure fieldsets referenced by buffered HIMPORT SETs are prepared on this 

4823 # node's connection before the MULTI/EXEC block (session state, not 

4824 # transactional). All keys share one slot here, so it is a single node. 

4825 self._himport_prepare_pipeline( 

4826 redis_node, connection, [(c.args, c.options) for c in stack] 

4827 ) 

4828 

4829 stack = chain( 

4830 [PipelineCommand(("MULTI",))], 

4831 stack, 

4832 [PipelineCommand(("EXEC",))], 

4833 ) 

4834 commands = [c.args for c in stack if EMPTY_RESPONSE not in c.options] 

4835 packed_commands = connection.pack_commands(commands) 

4836 

4837 # Start timing for observability 

4838 start_time = time.monotonic() 

4839 

4840 connection.send_packed_command(packed_commands) 

4841 errors = [] 

4842 

4843 # parse off the response for MULTI 

4844 # NOTE: we need to handle ResponseErrors here and continue 

4845 # so that we read all the additional command messages from 

4846 # the socket 

4847 try: 

4848 redis_node.parse_response(connection, "MULTI") 

4849 except ResponseError as e: 

4850 self.annotate_exception(e, 0, "MULTI") 

4851 errors.append(e) 

4852 except self.CONNECTION_ERRORS as cluster_error: 

4853 self.annotate_exception(cluster_error, 0, "MULTI") 

4854 raise 

4855 

4856 # and all the other commands 

4857 for i, command in enumerate(self._command_queue): 

4858 if EMPTY_RESPONSE in command.options: 

4859 errors.append((i, command.options[EMPTY_RESPONSE])) 

4860 else: 

4861 try: 

4862 _ = redis_node.parse_response(connection, "_") 

4863 except self.SLOT_REDIRECT_ERRORS as slot_error: 

4864 self.annotate_exception(slot_error, i + 1, command.args) 

4865 errors.append(slot_error) 

4866 except self.CONNECTION_ERRORS as cluster_error: 

4867 self.annotate_exception(cluster_error, i + 1, command.args) 

4868 raise 

4869 except ResponseError as e: 

4870 self.annotate_exception(e, i + 1, command.args) 

4871 errors.append(e) 

4872 

4873 response = None 

4874 # parse the EXEC. 

4875 try: 

4876 response = redis_node.parse_response(connection, "EXEC") 

4877 except ExecAbortError: 

4878 if errors: 

4879 raise errors[0] 

4880 raise 

4881 

4882 self._executing = False 

4883 

4884 record_operation_duration( 

4885 command_name="TRANSACTION", 

4886 duration_seconds=time.monotonic() - start_time, 

4887 server_address=connection.host, 

4888 server_port=connection.port, 

4889 db_namespace=str(connection.db), 

4890 ) 

4891 

4892 # EXEC clears any watched keys 

4893 self._watching = False 

4894 

4895 if response is None: 

4896 raise WatchError("Watched variable changed.") 

4897 

4898 # put any parse errors into the response 

4899 for i, e in errors: 

4900 response.insert(i, e) 

4901 

4902 if len(response) != len(self._command_queue): 

4903 raise InvalidPipelineStack( 

4904 "Unexpected response length for cluster pipeline EXEC." 

4905 " Command stack was {} but response had length {}".format( 

4906 [c.args[0] for c in self._command_queue], len(response) 

4907 ) 

4908 ) 

4909 

4910 # find any errors in the response and raise if necessary 

4911 if raise_on_error or len(errors) > 0: 

4912 self._raise_first_error( 

4913 response, 

4914 self._command_queue, 

4915 start_time, 

4916 ) 

4917 

4918 # We have to run response callbacks manually 

4919 data = [] 

4920 for r, cmd in zip(response, self._command_queue): 

4921 if not isinstance(r, Exception): 

4922 command_name = cmd.args[0] 

4923 if command_name in self._pipe.cluster_response_callbacks: 

4924 r = self._pipe.cluster_response_callbacks[command_name]( 

4925 r, **cmd.options 

4926 ) 

4927 data.append(r) 

4928 return data 

4929 

4930 def reset(self): 

4931 self._command_queue = [] 

4932 

4933 # make sure to reset the connection state in the event that we were 

4934 # watching something 

4935 if self._transaction_connection: 

4936 try: 

4937 if self._watching: 

4938 # call this manually since our unwatch or 

4939 # immediate_execute_command methods can call reset() 

4940 self._transaction_connection.send_command("UNWATCH") 

4941 self._transaction_connection.read_response() 

4942 # we can safely return the connection to the pool here since we're 

4943 # sure we're no longer WATCHing anything 

4944 node = self._nodes_manager.find_connection_owner( 

4945 self._transaction_connection 

4946 ) 

4947 if node and node.redis_connection: 

4948 node.redis_connection.connection_pool.release( 

4949 self._transaction_connection 

4950 ) 

4951 self._transaction_connection = None 

4952 except self.CONNECTION_ERRORS: 

4953 # disconnect will also remove any previous WATCHes 

4954 if self._transaction_connection: 

4955 self._transaction_connection.disconnect() 

4956 node = self._nodes_manager.find_connection_owner( 

4957 self._transaction_connection 

4958 ) 

4959 if node and node.redis_connection: 

4960 node.redis_connection.connection_pool.release( 

4961 self._transaction_connection 

4962 ) 

4963 self._transaction_connection = None 

4964 

4965 # clean up the other instance attributes 

4966 self._watching = False 

4967 self._explicit_transaction = False 

4968 self._pipeline_slots = set() 

4969 self._executing = False 

4970 

4971 def send_cluster_commands( 

4972 self, stack, raise_on_error=True, allow_redirections=True 

4973 ): 

4974 raise NotImplementedError( 

4975 "send_cluster_commands cannot be executed in transactional context." 

4976 ) 

4977 

4978 def multi(self): 

4979 if self._explicit_transaction: 

4980 raise RedisError("Cannot issue nested calls to MULTI") 

4981 if self._command_queue: 

4982 raise RedisError( 

4983 "Commands without an initial WATCH have already been issued" 

4984 ) 

4985 self._explicit_transaction = True 

4986 

4987 def watch(self, *names): 

4988 if self._explicit_transaction: 

4989 raise RedisError("Cannot issue a WATCH after a MULTI") 

4990 

4991 return self.execute_command("WATCH", *names) 

4992 

4993 def unwatch(self): 

4994 if self._watching: 

4995 return self.execute_command("UNWATCH") 

4996 

4997 return True 

4998 

4999 def discard(self): 

5000 self.reset() 

5001 

5002 def delete(self, *names): 

5003 return self.execute_command("DEL", *names) 

5004 

5005 def unlink(self, *names): 

5006 return self.execute_command("UNLINK", *names)