44import threading
55import time
66from collections import OrderedDict
7+ from enum import Enum
78from typing import Any , Callable , Dict , List , Optional , Tuple , Union
89
910from redis ._parsers import CommandsParser , Encoder
@@ -505,6 +506,11 @@ class initializer. In the case of conflicting arguments, querystring
505506 """
506507 return cls (url = url , ** kwargs )
507508
509+ @deprecated_args (
510+ args_to_warn = ["read_from_replicas" ],
511+ reason = "Please configure the 'load_balancing_strategy' instead" ,
512+ version = "5.0.3" ,
513+ )
508514 def __init__ (
509515 self ,
510516 host : Optional [str ] = None ,
@@ -515,6 +521,7 @@ def __init__(
515521 require_full_coverage : bool = False ,
516522 reinitialize_steps : int = 5 ,
517523 read_from_replicas : bool = False ,
524+ load_balancing_strategy : Optional ["LoadBalancingStrategy" ] = None ,
518525 dynamic_startup_nodes : bool = True ,
519526 url : Optional [str ] = None ,
520527 address_remap : Optional [Callable [[Tuple [str , int ]], Tuple [str , int ]]] = None ,
@@ -543,11 +550,16 @@ def __init__(
543550 cluster client. If not all slots are covered, RedisClusterException
544551 will be thrown.
545552 :param read_from_replicas:
553+ @deprecated - please use load_balancing_strategy instead
546554 Enable read from replicas in READONLY mode. You can read possibly
547555 stale data.
548556 When set to true, read commands will be assigned between the
549557 primary and its replications in a Round-Robin manner.
550- :param dynamic_startup_nodes:
558+ :param load_balancing_strategy:
559+ Enable read from replicas in READONLY mode and defines the load balancing
560+ strategy that will be used for cluster node selection.
561+ The data read from replicas is eventually consistent with the data in primary nodes.
562+ :param dynamic_startup_nodes:
551563 Set the RedisCluster's startup nodes to all of the discovered nodes.
552564 If true (default value), the cluster's discovered nodes will be used to
553565 determine the cluster nodes-slots mapping in the next topology refresh.
@@ -652,6 +664,7 @@ def __init__(
652664 self .command_flags = self .__class__ .COMMAND_FLAGS .copy ()
653665 self .node_flags = self .__class__ .NODE_FLAGS .copy ()
654666 self .read_from_replicas = read_from_replicas
667+ self .load_balancing_strategy = load_balancing_strategy
655668 self .reinitialize_counter = 0
656669 self .reinitialize_steps = reinitialize_steps
657670 if event_dispatcher is None :
@@ -704,7 +717,7 @@ def on_connect(self, connection):
704717 connection .set_parser (ClusterParser )
705718 connection .on_connect ()
706719
707- if self .read_from_replicas :
720+ if self .read_from_replicas or self . load_balancing_strategy :
708721 # Sending READONLY command to server to configure connection as
709722 # readonly. Since each cluster node may change its server type due
710723 # to a failover, we should establish a READONLY connection
@@ -831,6 +844,7 @@ def pipeline(self, transaction=None, shard_hint=None):
831844 cluster_response_callbacks = self .cluster_response_callbacks ,
832845 cluster_error_retry_attempts = self .cluster_error_retry_attempts ,
833846 read_from_replicas = self .read_from_replicas ,
847+ load_balancing_strategy = self .load_balancing_strategy ,
834848 reinitialize_steps = self .reinitialize_steps ,
835849 lock = self ._lock ,
836850 )
@@ -948,7 +962,9 @@ def _determine_nodes(self, *args, **kwargs) -> List["ClusterNode"]:
948962 # get the node that holds the key's slot
949963 slot = self .determine_slot (* args )
950964 node = self .nodes_manager .get_node_from_slot (
951- slot , self .read_from_replicas and command in READ_COMMANDS
965+ slot ,
966+ self .read_from_replicas and command in READ_COMMANDS ,
967+ self .load_balancing_strategy if command in READ_COMMANDS else None ,
952968 )
953969 return [node ]
954970
@@ -1172,7 +1188,11 @@ def _execute_command(self, target_node, *args, **kwargs):
11721188 # refresh the target node
11731189 slot = self .determine_slot (* args )
11741190 target_node = self .nodes_manager .get_node_from_slot (
1175- slot , self .read_from_replicas and command in READ_COMMANDS
1191+ slot ,
1192+ self .read_from_replicas and command in READ_COMMANDS ,
1193+ self .load_balancing_strategy
1194+ if command in READ_COMMANDS
1195+ else None ,
11761196 )
11771197 moved = False
11781198
@@ -1327,6 +1347,12 @@ def __del__(self):
13271347 self .redis_connection .close ()
13281348
13291349
1350+ class LoadBalancingStrategy (Enum ):
1351+ ROUND_ROBIN = "round_robin"
1352+ ROUND_ROBIN_REPLICAS = "round_robin_replicas"
1353+ RANDOM_REPLICA = "random_replica"
1354+
1355+
13301356class LoadBalancer :
13311357 """
13321358 Round-Robin Load Balancing
@@ -1336,15 +1362,38 @@ def __init__(self, start_index: int = 0) -> None:
13361362 self .primary_to_idx = {}
13371363 self .start_index = start_index
13381364
1339- def get_server_index (self , primary : str , list_size : int ) -> int :
1340- server_index = self .primary_to_idx .setdefault (primary , self .start_index )
1341- # Update the index
1342- self .primary_to_idx [primary ] = (server_index + 1 ) % list_size
1343- return server_index
1365+ def get_server_index (
1366+ self ,
1367+ primary : str ,
1368+ list_size : int ,
1369+ load_balancing_strategy : LoadBalancingStrategy = LoadBalancingStrategy .ROUND_ROBIN ,
1370+ ) -> int :
1371+ if load_balancing_strategy == LoadBalancingStrategy .RANDOM_REPLICA :
1372+ return self ._get_random_replica_index (list_size )
1373+ else :
1374+ return self ._get_round_robin_index (
1375+ primary ,
1376+ list_size ,
1377+ load_balancing_strategy == LoadBalancingStrategy .ROUND_ROBIN_REPLICAS ,
1378+ )
13441379
13451380 def reset (self ) -> None :
13461381 self .primary_to_idx .clear ()
13471382
1383+ def _get_random_replica_index (self , list_size : int ) -> int :
1384+ return random .randint (1 , list_size - 1 )
1385+
1386+ def _get_round_robin_index (
1387+ self , primary : str , list_size : int , replicas_only : bool
1388+ ) -> int :
1389+ server_index = self .primary_to_idx .setdefault (primary , self .start_index )
1390+ if replicas_only and server_index == 0 :
1391+ # skip the primary node index
1392+ server_index = 1
1393+ # Update the index for the next round
1394+ self .primary_to_idx [primary ] = (server_index + 1 ) % list_size
1395+ return server_index
1396+
13481397
13491398class NodesManager :
13501399 def __init__ (
@@ -1448,7 +1497,21 @@ def _update_moved_slots(self):
14481497 # Reset moved_exception
14491498 self ._moved_exception = None
14501499
1451- def get_node_from_slot (self , slot , read_from_replicas = False , server_type = None ):
1500+ @deprecated_args (
1501+ args_to_warn = ["server_type" ],
1502+ reason = (
1503+ "In case you need select some load balancing strategy "
1504+ "that will use replicas, please set it through 'load_balancing_strategy'"
1505+ ),
1506+ version = "5.0.3" ,
1507+ )
1508+ def get_node_from_slot (
1509+ self ,
1510+ slot ,
1511+ read_from_replicas = False ,
1512+ load_balancing_strategy = None ,
1513+ server_type = None ,
1514+ ):
14521515 """
14531516 Gets a node that servers this hash slot
14541517 """
@@ -1463,11 +1526,14 @@ def get_node_from_slot(self, slot, read_from_replicas=False, server_type=None):
14631526 f'"require_full_coverage={ self ._require_full_coverage } "'
14641527 )
14651528
1466- if read_from_replicas is True :
1467- # get the server index in a Round-Robin manner
1529+ if read_from_replicas is True and load_balancing_strategy is None :
1530+ load_balancing_strategy = LoadBalancingStrategy .ROUND_ROBIN
1531+
1532+ if len (self .slots_cache [slot ]) > 1 and load_balancing_strategy :
1533+ # get the server index using the strategy defined in load_balancing_strategy
14681534 primary_name = self .slots_cache [slot ][0 ].name
14691535 node_idx = self .read_load_balancer .get_server_index (
1470- primary_name , len (self .slots_cache [slot ])
1536+ primary_name , len (self .slots_cache [slot ]), load_balancing_strategy
14711537 )
14721538 elif (
14731539 server_type is None
@@ -1750,7 +1816,7 @@ def __init__(
17501816 first command execution. The node will be determined by:
17511817 1. Hashing the channel name in the request to find its keyslot
17521818 2. Selecting a node that handles the keyslot: If read_from_replicas is
1753- set to true, a replica can be selected.
1819+ set to true or load_balancing_strategy is set , a replica can be selected.
17541820
17551821 :type redis_cluster: RedisCluster
17561822 :type node: ClusterNode
@@ -1846,7 +1912,9 @@ def execute_command(self, *args):
18461912 channel = args [1 ]
18471913 slot = self .cluster .keyslot (channel )
18481914 node = self .cluster .nodes_manager .get_node_from_slot (
1849- slot , self .cluster .read_from_replicas
1915+ slot ,
1916+ self .cluster .read_from_replicas ,
1917+ self .cluster .load_balancing_strategy ,
18501918 )
18511919 else :
18521920 # Get a random node
@@ -1989,6 +2057,7 @@ def __init__(
19892057 cluster_response_callbacks : Optional [Dict [str , Callable ]] = None ,
19902058 startup_nodes : Optional [List ["ClusterNode" ]] = None ,
19912059 read_from_replicas : bool = False ,
2060+ load_balancing_strategy : Optional [LoadBalancingStrategy ] = None ,
19922061 cluster_error_retry_attempts : int = 3 ,
19932062 reinitialize_steps : int = 5 ,
19942063 lock = None ,
@@ -2004,6 +2073,7 @@ def __init__(
20042073 )
20052074 self .startup_nodes = startup_nodes if startup_nodes else []
20062075 self .read_from_replicas = read_from_replicas
2076+ self .load_balancing_strategy = load_balancing_strategy
20072077 self .command_flags = self .__class__ .COMMAND_FLAGS .copy ()
20082078 self .cluster_response_callbacks = cluster_response_callbacks
20092079 self .cluster_error_retry_attempts = cluster_error_retry_attempts
0 commit comments