1import enum
2import ipaddress
3import logging
4import re
5import threading
6import time
7from abc import ABC, abstractmethod
8from typing import (
9 TYPE_CHECKING,
10 Any,
11 Dict,
12 List,
13 Literal,
14 Mapping,
15 Optional,
16 Type,
17 Union,
18)
19
20from redis.event import (
21 EventDispatcherInterface,
22 MaintenanceCompletedEvent,
23 MaintenanceEvent,
24 MaintenanceStartedEvent,
25)
26from redis.observability.attributes import get_pool_name
27from redis.observability.recorder import (
28 record_connection_handoff,
29 record_connection_relaxed_timeout,
30 record_maint_notification_count,
31)
32from redis.typing import Number
33
34if TYPE_CHECKING:
35 from redis.cluster import MaintNotificationsAbstractRedisCluster
36
37logger = logging.getLogger(__name__)
38
39
40class MaintenanceState(enum.Enum):
41 NONE = "none"
42 MOVING = "moving"
43 MAINTENANCE = "maintenance"
44
45
46class EndpointType(enum.Enum):
47 """Valid endpoint types used in CLIENT MAINT_NOTIFICATIONS command."""
48
49 INTERNAL_IP = "internal-ip"
50 INTERNAL_FQDN = "internal-fqdn"
51 EXTERNAL_IP = "external-ip"
52 EXTERNAL_FQDN = "external-fqdn"
53 NONE = "none"
54
55 def __str__(self):
56 """Return the string value of the enum."""
57 return self.value
58
59
60if TYPE_CHECKING:
61 from redis.asyncio.connection import AsyncMaintNotificationsAbstractConnection
62 from redis.connection import (
63 MaintNotificationsAbstractConnection,
64 MaintNotificationsAbstractConnectionPool,
65 )
66
67
68class MaintenanceNotification(ABC):
69 """
70 Base class for maintenance notifications sent through push messages by Redis server.
71
72 This class provides common functionality for all maintenance notifications including
73 unique identification and TTL (Time-To-Live) functionality.
74
75 Attributes:
76 id (int): Unique identifier for this notification
77 ttl (int): Time-to-live in seconds for this notification
78 creation_time (float): Timestamp when the notification was created/read
79 """
80
81 def __init__(self, id: int, ttl: int):
82 """
83 Initialize a new MaintenanceNotification with unique ID and TTL functionality.
84
85 Args:
86 id (int): Unique identifier for this notification
87 ttl (int): Time-to-live in seconds for this notification
88 """
89 self.id = id
90 self.ttl = ttl
91 self.creation_time = time.monotonic()
92 self.expire_at = self.creation_time + self.ttl
93
94 def is_expired(self) -> bool:
95 """
96 Check if this notification has expired based on its TTL
97 and creation time.
98
99 Returns:
100 bool: True if the notification has expired, False otherwise
101 """
102 return time.monotonic() > (self.creation_time + self.ttl)
103
104 @abstractmethod
105 def __repr__(self) -> str:
106 """
107 Return a string representation of the maintenance notification.
108
109 This method must be implemented by all concrete subclasses.
110
111 Returns:
112 str: String representation of the notification
113 """
114 pass
115
116 @abstractmethod
117 def __eq__(self, other) -> bool:
118 """
119 Compare two maintenance notifications for equality.
120
121 This method must be implemented by all concrete subclasses.
122 Notifications are typically considered equal if they have the same id
123 and are of the same type.
124
125 Args:
126 other: The other object to compare with
127
128 Returns:
129 bool: True if the notifications are equal, False otherwise
130 """
131 pass
132
133 @abstractmethod
134 def __hash__(self) -> int:
135 """
136 Return a hash value for the maintenance notification.
137
138 This method must be implemented by all concrete subclasses to allow
139 instances to be used in sets and as dictionary keys.
140
141 Returns:
142 int: Hash value for the notification
143 """
144 pass
145
146
147class NodeMovingNotification(MaintenanceNotification):
148 """
149 This notification is received when a node is replaced with a new node
150 during cluster rebalancing or maintenance operations.
151 """
152
153 def __init__(
154 self,
155 id: int,
156 new_node_host: Optional[str],
157 new_node_port: Optional[int],
158 ttl: int,
159 ):
160 """
161 Initialize a new NodeMovingNotification.
162
163 Args:
164 id (int): Unique identifier for this notification
165 new_node_host (str): Hostname or IP address of the new replacement node
166 new_node_port (int): Port number of the new replacement node
167 ttl (int): Time-to-live in seconds for this notification
168 """
169 super().__init__(id, ttl)
170 self.new_node_host = new_node_host
171 self.new_node_port = new_node_port
172
173 def __repr__(self) -> str:
174 expiry_time = self.expire_at
175 remaining = max(0, expiry_time - time.monotonic())
176
177 return (
178 f"{self.__class__.__name__}("
179 f"id={self.id}, "
180 f"new_node_host='{self.new_node_host}', "
181 f"new_node_port={self.new_node_port}, "
182 f"ttl={self.ttl}, "
183 f"creation_time={self.creation_time}, "
184 f"expires_at={expiry_time}, "
185 f"remaining={remaining:.1f}s, "
186 f"expired={self.is_expired()}"
187 f")"
188 )
189
190 def __eq__(self, other) -> bool:
191 """
192 Two NodeMovingNotification notifications are considered equal if they have the same
193 id, new_node_host, and new_node_port.
194 """
195 if not isinstance(other, NodeMovingNotification):
196 return False
197 return (
198 self.id == other.id
199 and self.new_node_host == other.new_node_host
200 and self.new_node_port == other.new_node_port
201 )
202
203 def __hash__(self) -> int:
204 """
205 Return a hash value for the notification to allow
206 instances to be used in sets and as dictionary keys.
207
208 Returns:
209 int: Hash value based on notification type class name, id,
210 new_node_host and new_node_port
211 """
212 try:
213 node_port = int(self.new_node_port) if self.new_node_port else None
214 except ValueError:
215 node_port = 0
216
217 return hash(
218 (
219 self.__class__.__name__,
220 int(self.id),
221 str(self.new_node_host),
222 node_port,
223 )
224 )
225
226
227class NodeMigratingNotification(MaintenanceNotification):
228 """
229 Notification for when a Redis cluster node is in the process of migrating slots.
230
231 This notification is received when a node starts migrating its slots to another node
232 during cluster rebalancing or maintenance operations.
233
234 Args:
235 id (int): Unique identifier for this notification
236 ttl (int): Time-to-live in seconds for this notification
237 """
238
239 def __init__(self, id: int, ttl: int):
240 super().__init__(id, ttl)
241
242 def __repr__(self) -> str:
243 expiry_time = self.creation_time + self.ttl
244 remaining = max(0, expiry_time - time.monotonic())
245 return (
246 f"{self.__class__.__name__}("
247 f"id={self.id}, "
248 f"ttl={self.ttl}, "
249 f"creation_time={self.creation_time}, "
250 f"expires_at={expiry_time}, "
251 f"remaining={remaining:.1f}s, "
252 f"expired={self.is_expired()}"
253 f")"
254 )
255
256 def __eq__(self, other) -> bool:
257 """
258 Two NodeMigratingNotification notifications are considered equal if they have the same
259 id and are of the same type.
260 """
261 if not isinstance(other, NodeMigratingNotification):
262 return False
263 return self.id == other.id and type(self) is type(other)
264
265 def __hash__(self) -> int:
266 """
267 Return a hash value for the notification to allow
268 instances to be used in sets and as dictionary keys.
269
270 Returns:
271 int: Hash value based on notification type and id
272 """
273 return hash((self.__class__.__name__, int(self.id)))
274
275
276class NodeMigratedNotification(MaintenanceNotification):
277 """
278 Notification for when a Redis cluster node has completed migrating slots.
279
280 This notification is received when a node has finished migrating all its slots
281 to other nodes during cluster rebalancing or maintenance operations.
282
283 Args:
284 id (int): Unique identifier for this notification
285 """
286
287 DEFAULT_TTL = 5
288
289 def __init__(self, id: int):
290 super().__init__(id, NodeMigratedNotification.DEFAULT_TTL)
291
292 def __repr__(self) -> str:
293 expiry_time = self.creation_time + self.ttl
294 remaining = max(0, expiry_time - time.monotonic())
295 return (
296 f"{self.__class__.__name__}("
297 f"id={self.id}, "
298 f"ttl={self.ttl}, "
299 f"creation_time={self.creation_time}, "
300 f"expires_at={expiry_time}, "
301 f"remaining={remaining:.1f}s, "
302 f"expired={self.is_expired()}"
303 f")"
304 )
305
306 def __eq__(self, other) -> bool:
307 """
308 Two NodeMigratedNotification notifications are considered equal if they have the same
309 id and are of the same type.
310 """
311 if not isinstance(other, NodeMigratedNotification):
312 return False
313 return self.id == other.id and type(self) is type(other)
314
315 def __hash__(self) -> int:
316 """
317 Return a hash value for the notification to allow
318 instances to be used in sets and as dictionary keys.
319
320 Returns:
321 int: Hash value based on notification type and id
322 """
323 return hash((self.__class__.__name__, int(self.id)))
324
325
326class NodeFailingOverNotification(MaintenanceNotification):
327 """
328 Notification for when a Redis cluster node is in the process of failing over.
329
330 This notification is received when a node starts a failover process during
331 cluster maintenance operations or when handling node failures.
332
333 Args:
334 id (int): Unique identifier for this notification
335 ttl (int): Time-to-live in seconds for this notification
336 """
337
338 def __init__(self, id: int, ttl: int):
339 super().__init__(id, ttl)
340
341 def __repr__(self) -> str:
342 expiry_time = self.creation_time + self.ttl
343 remaining = max(0, expiry_time - time.monotonic())
344 return (
345 f"{self.__class__.__name__}("
346 f"id={self.id}, "
347 f"ttl={self.ttl}, "
348 f"creation_time={self.creation_time}, "
349 f"expires_at={expiry_time}, "
350 f"remaining={remaining:.1f}s, "
351 f"expired={self.is_expired()}"
352 f")"
353 )
354
355 def __eq__(self, other) -> bool:
356 """
357 Two NodeFailingOverNotification notifications are considered equal if they have the same
358 id and are of the same type.
359 """
360 if not isinstance(other, NodeFailingOverNotification):
361 return False
362 return self.id == other.id and type(self) is type(other)
363
364 def __hash__(self) -> int:
365 """
366 Return a hash value for the notification to allow
367 instances to be used in sets and as dictionary keys.
368
369 Returns:
370 int: Hash value based on notification type and id
371 """
372 return hash((self.__class__.__name__, int(self.id)))
373
374
375class NodeFailedOverNotification(MaintenanceNotification):
376 """
377 Notification for when a Redis cluster node has completed a failover.
378
379 This notification is received when a node has finished the failover process
380 during cluster maintenance operations or after handling node failures.
381
382 Args:
383 id (int): Unique identifier for this notification
384 """
385
386 DEFAULT_TTL = 5
387
388 def __init__(self, id: int):
389 super().__init__(id, NodeFailedOverNotification.DEFAULT_TTL)
390
391 def __repr__(self) -> str:
392 expiry_time = self.creation_time + self.ttl
393 remaining = max(0, expiry_time - time.monotonic())
394 return (
395 f"{self.__class__.__name__}("
396 f"id={self.id}, "
397 f"ttl={self.ttl}, "
398 f"creation_time={self.creation_time}, "
399 f"expires_at={expiry_time}, "
400 f"remaining={remaining:.1f}s, "
401 f"expired={self.is_expired()}"
402 f")"
403 )
404
405 def __eq__(self, other) -> bool:
406 """
407 Two NodeFailedOverNotification notifications are considered equal if they have the same
408 id and are of the same type.
409 """
410 if not isinstance(other, NodeFailedOverNotification):
411 return False
412 return self.id == other.id and type(self) is type(other)
413
414 def __hash__(self) -> int:
415 """
416 Return a hash value for the notification to allow
417 instances to be used in sets and as dictionary keys.
418
419 Returns:
420 int: Hash value based on notification type and id
421 """
422 return hash((self.__class__.__name__, int(self.id)))
423
424
425class OSSNodeMigratingNotification(MaintenanceNotification):
426 """
427 Notification for when a Redis OSS API client is used and a node is in the process of migrating slots.
428
429 This notification is received when a node starts migrating its slots to another node
430 during cluster rebalancing or maintenance operations.
431
432 Args:
433 id (int): Unique identifier for this notification
434 slots (Optional[List[int]]): List of slots being migrated
435 """
436
437 DEFAULT_TTL = 30
438
439 def __init__(
440 self,
441 id: int,
442 slots: Optional[str] = None,
443 ):
444 super().__init__(id, OSSNodeMigratingNotification.DEFAULT_TTL)
445 self.slots = slots
446
447 def __repr__(self) -> str:
448 expiry_time = self.creation_time + self.ttl
449 remaining = max(0, expiry_time - time.monotonic())
450 return (
451 f"{self.__class__.__name__}("
452 f"id={self.id}, "
453 f"slots={self.slots}, "
454 f"ttl={self.ttl}, "
455 f"creation_time={self.creation_time}, "
456 f"expires_at={expiry_time}, "
457 f"remaining={remaining:.1f}s, "
458 f"expired={self.is_expired()}"
459 f")"
460 )
461
462 def __eq__(self, other) -> bool:
463 """
464 Two OSSNodeMigratingNotification notifications are considered equal if they have the same
465 id and are of the same type.
466 """
467 if not isinstance(other, OSSNodeMigratingNotification):
468 return False
469 return self.id == other.id and type(self) is type(other)
470
471 def __hash__(self) -> int:
472 """
473 Return a hash value for the notification to allow
474 instances to be used in sets and as dictionary keys.
475
476 Returns:
477 int: Hash value based on notification type and id
478 """
479 return hash((self.__class__.__name__, int(self.id)))
480
481
482class OSSNodeMigratedNotification(MaintenanceNotification):
483 """
484 Notification for when a Redis OSS API client is used and a node has completed migrating slots.
485
486 This notification is received when a node has finished migrating all its slots
487 to other nodes during cluster rebalancing or maintenance operations.
488
489 Args:
490 id (int): Unique identifier for this notification
491 nodes_to_slots_mapping (Dict[str, List[Dict[str, str]]]): Map of source node address
492 to list of destination mappings. Each destination mapping is a dict with
493 the destination node address as key and the slot range as value.
494
495 Structure example:
496 {
497 "127.0.0.1:6379": [
498 {"127.0.0.1:6380": "1-100"},
499 {"127.0.0.1:6381": "101-200"}
500 ],
501 "127.0.0.1:6382": [
502 {"127.0.0.1:6383": "201-300"}
503 ]
504 }
505
506 Where:
507 - Key (str): Source node address in "host:port" format
508 - Value (List[Dict[str, str]]): List of destination mappings where each dict
509 contains destination node address as key and slot range as value
510 """
511
512 DEFAULT_TTL = 120
513
514 def __init__(
515 self,
516 id: int,
517 nodes_to_slots_mapping: Dict[str, List[Dict[str, str]]],
518 ):
519 super().__init__(id, OSSNodeMigratedNotification.DEFAULT_TTL)
520 self.nodes_to_slots_mapping = nodes_to_slots_mapping
521
522 def __repr__(self) -> str:
523 expiry_time = self.creation_time + self.ttl
524 remaining = max(0, expiry_time - time.monotonic())
525 return (
526 f"{self.__class__.__name__}("
527 f"id={self.id}, "
528 f"nodes_to_slots_mapping={self.nodes_to_slots_mapping}, "
529 f"ttl={self.ttl}, "
530 f"creation_time={self.creation_time}, "
531 f"expires_at={expiry_time}, "
532 f"remaining={remaining:.1f}s, "
533 f"expired={self.is_expired()}"
534 f")"
535 )
536
537 def __eq__(self, other) -> bool:
538 """
539 Two OSSNodeMigratedNotification notifications are considered equal if they have the same
540 id and are of the same type.
541 """
542 if not isinstance(other, OSSNodeMigratedNotification):
543 return False
544 return self.id == other.id and type(self) is type(other)
545
546 def __hash__(self) -> int:
547 """
548 Return a hash value for the notification to allow
549 instances to be used in sets and as dictionary keys.
550
551 Returns:
552 int: Hash value based on notification type and id
553 """
554 return hash((self.__class__.__name__, int(self.id)))
555
556
557def _is_private_fqdn(host: str) -> bool:
558 """
559 Determine if an FQDN is likely to be internal/private.
560
561 This uses heuristics based on RFC 952 and RFC 1123 standards:
562 - .local domains (RFC 6762 - Multicast DNS)
563 - .internal domains (common internal convention)
564 - Single-label hostnames (no dots)
565 - Common internal TLDs
566
567 Args:
568 host (str): The FQDN to check
569
570 Returns:
571 bool: True if the FQDN appears to be internal/private
572 """
573 host_lower = host.lower().rstrip(".")
574
575 # Single-label hostnames (no dots) are typically internal
576 if "." not in host_lower:
577 return True
578
579 # Common internal/private domain patterns
580 internal_patterns = [
581 r"\.local$", # mDNS/Bonjour domains
582 r"\.internal$", # Common internal convention
583 r"\.corp$", # Corporate domains
584 r"\.lan$", # Local area network
585 r"\.intranet$", # Intranet domains
586 r"\.private$", # Private domains
587 ]
588
589 for pattern in internal_patterns:
590 if re.search(pattern, host_lower):
591 return True
592
593 # If none of the internal patterns match, assume it's external
594 return False
595
596
597notification_types_mapping: dict[type[MaintenanceNotification], str] = {
598 NodeMovingNotification: "MOVING",
599 NodeMigratingNotification: "MIGRATING",
600 NodeMigratedNotification: "MIGRATED",
601 NodeFailingOverNotification: "FAILING_OVER",
602 NodeFailedOverNotification: "FAILED_OVER",
603 OSSNodeMigratingNotification: "SMIGRATING",
604 OSSNodeMigratedNotification: "SMIGRATED",
605}
606
607
608def add_debug_log_for_notification(
609 connection: "MaintNotificationsAbstractConnection",
610 notification: Union[str, MaintenanceNotification],
611):
612 if logger.isEnabledFor(logging.DEBUG):
613 logger.debug(
614 f"Handling maintenance notification: {notification}, "
615 f"with connection: {connection}, "
616 f"{connection.extract_connection_details() if connection else 'no connection'}",
617 )
618
619
620class MaintNotificationsConfig:
621 """
622 Configuration class for maintenance notifications handling behaviour. Notifications are received through
623 push notifications.
624
625 This class defines how the Redis client should react to different push notifications
626 such as node moving, migrations, etc. in a Redis cluster.
627
628 """
629
630 def __init__(
631 self,
632 enabled: Union[bool, Literal["auto"]] = "auto",
633 proactive_reconnect: bool = True,
634 relaxed_timeout: Optional[Number] = 10,
635 endpoint_type: Optional[EndpointType] = None,
636 event_dispatcher: Optional[EventDispatcherInterface] = None,
637 ):
638 """
639 Initialize a new MaintNotificationsConfig.
640
641 Args:
642 enabled (bool | "auto"): Controls maintenance notifications handling behavior.
643 - True: The CLIENT MAINT_NOTIFICATIONS command must succeed during connection setup,
644 otherwise a ResponseError is raised.
645 - "auto": The CLIENT MAINT_NOTIFICATIONS command is attempted but failures are
646 gracefully handled - a warning is logged and normal operation continues.
647 - False: Maintenance notifications are completely disabled.
648 Defaults to "auto".
649 proactive_reconnect (bool): Whether to proactively reconnect when a node is replaced.
650 Defaults to True.
651 relaxed_timeout (Number): The relaxed timeout to use for the connection during maintenance.
652 If -1 is provided - the relaxed timeout is disabled. If None is provided - the
653 affected operations become blocking. Defaults to 10.
654 endpoint_type (Optional[EndpointType]): Override for the endpoint type to use in CLIENT MAINT_NOTIFICATIONS.
655 If None, the endpoint type will be automatically determined based on the host and TLS configuration.
656 Defaults to None.
657 event_dispatcher (Optional[EventDispatcherInterface]): Dispatcher that receives a
658 ``MaintenanceStartedEvent`` when a notification starts relaxing timeouts and a
659 ``MaintenanceCompletedEvent`` when the relaxation is reverted. Dispatched
660 synchronously from the handlers, so listeners must not block. If None,
661 no events are dispatched. Defaults to None.
662
663 Raises:
664 ValueError: If endpoint_type is provided but is not a valid endpoint type.
665 """
666 self.enabled = enabled
667 self.relaxed_timeout = relaxed_timeout
668 self.proactive_reconnect = proactive_reconnect
669 self.endpoint_type = endpoint_type
670 self.event_dispatcher = event_dispatcher
671
672 def __repr__(self) -> str:
673 return (
674 f"{self.__class__.__name__}("
675 f"enabled={self.enabled}, "
676 f"proactive_reconnect={self.proactive_reconnect}, "
677 f"relaxed_timeout={self.relaxed_timeout}, "
678 f"endpoint_type={self.endpoint_type!r}, "
679 f"event_dispatcher={self.event_dispatcher!r}"
680 f")"
681 )
682
683 def is_relaxed_timeouts_enabled(self) -> bool:
684 """
685 Check if the relaxed_timeout is enabled. The '-1' value is used to disable the relaxed_timeout.
686 If relaxed_timeout is set to None, it will make the operation blocking
687 and waiting until any response is received.
688
689 Returns:
690 True if the relaxed_timeout is enabled, False otherwise.
691 """
692 return self.relaxed_timeout != -1
693
694 def get_endpoint_type(
695 self,
696 host: str,
697 connection: "MaintNotificationsAbstractConnection | AsyncMaintNotificationsAbstractConnection",
698 ) -> EndpointType:
699 """
700 Determine the appropriate endpoint type for CLIENT MAINT_NOTIFICATIONS command.
701
702 Logic:
703 1. If endpoint_type is explicitly set, use it
704 2. Otherwise, check the original host from connection.host:
705 - If host is an IP address, use it directly to determine internal-ip vs external-ip
706 - If host is an FQDN, get the resolved IP to determine internal-fqdn vs external-fqdn
707
708 Args:
709 host: User provided hostname to analyze
710 connection: The connection object to analyze for endpoint type determination
711
712 Returns:
713 """
714
715 # If endpoint_type is explicitly set, use it
716 if self.endpoint_type is not None:
717 return self.endpoint_type
718
719 # Check if the host is an IP address
720 try:
721 ip_addr = ipaddress.ip_address(host)
722 # Host is an IP address - use it directly
723 is_private = ip_addr.is_private
724 return EndpointType.INTERNAL_IP if is_private else EndpointType.EXTERNAL_IP
725 except ValueError:
726 # Host is an FQDN - need to check resolved IP to determine internal vs external
727 pass
728
729 # Host is an FQDN, get the resolved IP to determine if it's internal or external
730 resolved_ip = connection.get_resolved_ip()
731
732 if resolved_ip:
733 try:
734 ip_addr = ipaddress.ip_address(resolved_ip)
735 is_private = ip_addr.is_private
736 # Use FQDN types since the original host was an FQDN
737 return (
738 EndpointType.INTERNAL_FQDN
739 if is_private
740 else EndpointType.EXTERNAL_FQDN
741 )
742 except ValueError:
743 # This shouldn't happen since we got the IP from the socket, but fallback
744 pass
745
746 # Final fallback: use heuristics on the FQDN itself
747 is_private = _is_private_fqdn(host)
748 return EndpointType.INTERNAL_FQDN if is_private else EndpointType.EXTERNAL_FQDN
749
750
751_MAINTENANCE_START_NOTIFICATION_TYPES = (
752 NodeMigratingNotification,
753 NodeFailingOverNotification,
754 OSSNodeMigratingNotification,
755)
756_MAINTENANCE_COMPLETED_NOTIFICATION_TYPES = (
757 NodeMigratedNotification,
758 NodeFailedOverNotification,
759 OSSNodeMigratedNotification,
760)
761
762
763def _get_maintenance_notification_type(
764 notification: MaintenanceNotification,
765) -> Optional[int]:
766 if notification.__class__ in _MAINTENANCE_START_NOTIFICATION_TYPES:
767 return 1
768 if notification.__class__ in _MAINTENANCE_COMPLETED_NOTIFICATION_TYPES:
769 return 0
770 return None
771
772
773def _get_maintenance_notification_name(
774 notification: MaintenanceNotification,
775) -> str:
776 return notification_types_mapping.get(notification.__class__, "")
777
778
779def _should_skip_connection_timeout_update(
780 maintenance_state: MaintenanceState,
781 config: MaintNotificationsConfig,
782) -> bool:
783 return (
784 maintenance_state == MaintenanceState.MOVING
785 or not config.is_relaxed_timeouts_enabled()
786 )
787
788
789# How long a pooled connection's pending push notifications are read for, in
790# total, when it is handed out. The notifications are already buffered, so a
791# complete frame costs no wait at all; the bound is for a frame only partly
792# received, which would otherwise block the checkout for the connection's socket
793# timeout - or, with a blocking socket, indefinitely.
794PENDING_PUSH_NOTIFICATIONS_READ_TIMEOUT = 1.0
795
796
797def _dispatch_maintenance_event(
798 config: MaintNotificationsConfig,
799 event: MaintenanceEvent,
800) -> None:
801 """
802 Dispatch a maintenance event to the dispatcher configured on ``config``,
803 if any. A failing listener is logged and never interrupts the notification
804 handling that emitted the event: the push parser swallows exceptions
805 silently, so letting one out would abort the remaining handling instead.
806 """
807 dispatcher = config.event_dispatcher
808 if dispatcher is None:
809 return
810 try:
811 dispatcher.dispatch(event)
812 except Exception:
813 logger.exception(f"Error dispatching maintenance event: {event}")
814
815
816def _build_moving_connection_kwargs(
817 notification: NodeMovingNotification,
818 config: MaintNotificationsConfig,
819) -> dict[str, Any]:
820 kwargs: dict[str, Any] = {
821 "maintenance_state": MaintenanceState.MOVING,
822 "maintenance_notification_hash": hash(notification),
823 }
824 if notification.new_node_host is not None:
825 # the host is not updated if the new node host is None
826 # this happens when the MOVING push notification does not contain
827 # the new node host - in this case we only update the timeouts
828 kwargs["host"] = notification.new_node_host
829 if config.is_relaxed_timeouts_enabled():
830 kwargs.update(
831 {
832 "socket_timeout": config.relaxed_timeout,
833 "socket_connect_timeout": config.relaxed_timeout,
834 }
835 )
836 return kwargs
837
838
839def _build_moving_cleanup_connection_kwargs(
840 connection_kwargs: Mapping[str, Any],
841 notification_hash: int,
842) -> Optional[dict[str, Any]]:
843 # if the current maintenance_notification_hash in kwargs is not matching the notification
844 # it means there has been a new moving notification after this one
845 # and we don't need to revert the kwargs yet
846 if connection_kwargs.get("maintenance_notification_hash") != notification_hash:
847 return None
848
849 return {
850 "maintenance_state": MaintenanceState.NONE,
851 "maintenance_notification_hash": None,
852 "host": connection_kwargs.get("orig_host_address"),
853 "socket_timeout": connection_kwargs.get("orig_socket_timeout"),
854 "socket_connect_timeout": connection_kwargs.get("orig_socket_connect_timeout"),
855 }
856
857
858class MaintNotificationsPoolHandler:
859 def __init__(
860 self,
861 pool: "MaintNotificationsAbstractConnectionPool",
862 config: MaintNotificationsConfig,
863 ) -> None:
864 self.pool = pool
865 self.config = config
866 self._processed_notifications = set()
867 self._lock = threading.RLock()
868 self.connection = None
869
870 def set_connection(self, connection: "MaintNotificationsAbstractConnection"):
871 self.connection = connection
872
873 def get_handler_for_connection(self):
874 # Copy all data that should be shared between connections
875 # but each connection should have its own pool handler
876 # since each connection can be in a different state
877 copy = MaintNotificationsPoolHandler(self.pool, self.config)
878 copy._processed_notifications = self._processed_notifications
879 copy._lock = self._lock
880 copy.connection = None
881 return copy
882
883 def remove_expired_notifications(self):
884 with self._lock:
885 for notification in tuple(self._processed_notifications):
886 if notification.is_expired():
887 self._processed_notifications.remove(notification)
888
889 def handle_notification(self, notification: MaintenanceNotification):
890 self.remove_expired_notifications()
891
892 if isinstance(notification, NodeMovingNotification):
893 return self.handle_node_moving_notification(notification)
894 else:
895 logger.error(f"Unhandled notification type: {notification}")
896
897 def handle_node_moving_notification(self, notification: NodeMovingNotification):
898 if (
899 not self.config.proactive_reconnect
900 and not self.config.is_relaxed_timeouts_enabled()
901 ):
902 return
903
904 with self._lock:
905 if notification in self._processed_notifications:
906 # nothing to do in the connection pool handling
907 # the notification has already been handled or is expired
908 # just return
909 return
910
911 if logger.isEnabledFor(logging.DEBUG):
912 logger.debug(
913 f"Handling node MOVING notification: {notification}, "
914 f"with connection: {self.connection}, connected to ip "
915 f"{self.connection.get_resolved_ip() if self.connection else None}"
916 )
917 # Get the current connected address - if any
918 # This is the address that is being moved
919 # and we need to handle only connections
920 # connected to the same address
921 moving_address_src = (
922 self.connection.getpeername() if self.connection else None
923 )
924
925 # Reported before the pool is relaxed: taking over a connection
926 # under maintenance completes that connection's pair, and a
927 # listener must see the handoff's pair open by then rather than
928 # no relaxation at all.
929 self._dispatch_handoff_event(MaintenanceStartedEvent, notification)
930
931 try:
932 with self.pool._lock:
933 if getattr(self.pool, "set_in_maintenance", False):
934 # Set pool in maintenance mode - executed only if
935 # BlockingConnectionPool is used
936 self.pool.set_in_maintenance(True)
937
938 # Update maintenance state, timeout and optionally host
939 # address connection settings for matching connections
940 self.pool.update_connections_settings(
941 state=MaintenanceState.MOVING,
942 maintenance_notification_hash=hash(notification),
943 relaxed_timeout=self.config.relaxed_timeout,
944 host_address=notification.new_node_host,
945 matching_address=moving_address_src,
946 matching_pattern="connected_address",
947 update_notification_hash=True,
948 include_free_connections=True,
949 )
950
951 if self.config.proactive_reconnect:
952 if notification.new_node_host is not None:
953 self.run_proactive_reconnect(moving_address_src)
954 else:
955 threading.Timer(
956 notification.ttl / 2,
957 self.run_proactive_reconnect,
958 args=(moving_address_src,),
959 ).start()
960
961 # Update config for new connections:
962 # Set state to MOVING
963 # update host
964 # if relax timeouts are enabled - update timeouts
965 self.pool.update_connection_kwargs(
966 **_build_moving_connection_kwargs(notification, self.config)
967 )
968
969 if getattr(self.pool, "set_in_maintenance", False):
970 self.pool.set_in_maintenance(False)
971
972 threading.Timer(
973 notification.ttl,
974 self.handle_node_moved_notification,
975 args=(notification,),
976 ).start()
977 except BaseException:
978 # The relaxation reported above was not applied, or has no
979 # cleanup to end it: close its pair
980 self._dispatch_handoff_event(MaintenanceCompletedEvent, notification)
981 raise
982
983 record_connection_handoff(
984 pool_name=get_pool_name(self.pool),
985 )
986
987 self._processed_notifications.add(notification)
988
989 def _dispatch_handoff_event(
990 self,
991 event_type: Type[MaintenanceEvent],
992 notification: NodeMovingNotification,
993 ) -> None:
994 """
995 Report a handoff's relaxation. The events report relaxed timeouts; a
996 handoff handled only for the proactive reconnect relaxes nothing.
997 """
998 if self.config.is_relaxed_timeouts_enabled():
999 _dispatch_maintenance_event(
1000 self.config,
1001 event_type(
1002 connection_pool=self.pool,
1003 connection=self.connection,
1004 state=MaintenanceState.MOVING,
1005 notification=notification,
1006 config=self.config,
1007 ),
1008 )
1009
1010 def run_proactive_reconnect(self, moving_address_src: Optional[str] = None):
1011 """
1012 Run proactive reconnect for the pool.
1013 Active connections are marked for reconnect after they complete the current command.
1014 Inactive connections are disconnected and will be connected on next use.
1015 """
1016 with self._lock:
1017 with self.pool._lock:
1018 # take care for the active connections in the pool
1019 # mark them for reconnect after they complete the current command
1020 self.pool.update_active_connections_for_reconnect(
1021 moving_address_src=moving_address_src,
1022 )
1023 # take care for the inactive connections in the pool
1024 # delete them and create new ones
1025 self.pool.disconnect_free_connections(
1026 moving_address_src=moving_address_src,
1027 )
1028
1029 def handle_node_moved_notification(self, notification: NodeMovingNotification):
1030 """
1031 Handle the cleanup after a node moving notification expires.
1032 """
1033 notification_hash = hash(notification)
1034
1035 with self._lock:
1036 if logger.isEnabledFor(logging.DEBUG):
1037 logger.debug(
1038 f"Reverting temporary changes related to notification: {notification}, "
1039 f"with connection: {self.connection}, connected to ip "
1040 f"{self.connection.get_resolved_ip() if self.connection else None}"
1041 )
1042 kwargs = _build_moving_cleanup_connection_kwargs(
1043 self.pool.connection_kwargs, notification_hash
1044 )
1045 if kwargs is not None:
1046 self.pool.update_connection_kwargs(**kwargs)
1047
1048 with self.pool._lock:
1049 reset_relaxed_timeout = self.config.is_relaxed_timeouts_enabled()
1050 reset_host_address = self.config.proactive_reconnect
1051
1052 self.pool.update_connections_settings(
1053 relaxed_timeout=-1,
1054 state=MaintenanceState.NONE,
1055 maintenance_notification_hash=None,
1056 matching_notification_hash=notification_hash,
1057 matching_pattern="notification_hash",
1058 update_notification_hash=True,
1059 reset_relaxed_timeout=reset_relaxed_timeout,
1060 reset_host_address=reset_host_address,
1061 include_free_connections=True,
1062 )
1063
1064 # A newer MOVING notification has superseded this one when the
1065 # kwargs were not reverted: the pool stays relaxed until it expires.
1066 if kwargs is not None:
1067 self._dispatch_handoff_event(MaintenanceCompletedEvent, notification)
1068
1069
1070class MaintNotificationsConnectionHandler:
1071 # 1 = "starting maintenance" notifications, 0 = "completed maintenance" notifications
1072 _NOTIFICATION_TYPES: dict[type["MaintenanceNotification"], int] = {
1073 NodeMigratingNotification: 1,
1074 NodeFailingOverNotification: 1,
1075 OSSNodeMigratingNotification: 1,
1076 NodeMigratedNotification: 0,
1077 NodeFailedOverNotification: 0,
1078 OSSNodeMigratedNotification: 0,
1079 }
1080
1081 def __init__(
1082 self,
1083 connection: "MaintNotificationsAbstractConnection",
1084 config: MaintNotificationsConfig,
1085 ) -> None:
1086 self.connection = connection
1087 self.config = config
1088
1089 def _get_pool(self) -> Optional["MaintNotificationsAbstractConnectionPool"]:
1090 """
1091 Get the pool from the connection's pool handler, or None for
1092 standalone connections without a pool.
1093 """
1094 pool_handler = getattr(
1095 self.connection, "_maint_notifications_pool_handler", None
1096 )
1097 if pool_handler:
1098 return getattr(pool_handler, "pool", None)
1099 return None
1100
1101 def _get_pool_name(self) -> str:
1102 """
1103 Get the pool name from the connection's pool handler.
1104 Falls back to connection representation if pool is not available.
1105 """
1106 pool = self._get_pool()
1107 if pool:
1108 return get_pool_name(pool)
1109 # Fallback for standalone connections without a pool
1110 return repr(self.connection)
1111
1112 def handle_notification(self, notification: MaintenanceNotification):
1113 # get the notification type by checking its class
1114 # 1 for start, 0 for end notification type, None for unknown
1115 notification_type = _get_maintenance_notification_type(notification)
1116 maint_notification = _get_maintenance_notification_name(notification)
1117
1118 record_maint_notification_count(
1119 server_address=self.connection.host,
1120 server_port=self.connection.port,
1121 network_peer_address=self.connection.host,
1122 network_peer_port=self.connection.port,
1123 maint_notification=maint_notification,
1124 )
1125
1126 if notification_type is None:
1127 logger.error(f"Unhandled notification type: {notification}")
1128 return
1129
1130 if notification_type:
1131 self.handle_maintenance_start_notification(
1132 MaintenanceState.MAINTENANCE, notification
1133 )
1134 else:
1135 self.handle_maintenance_completed_notification(notification=notification)
1136
1137 def handle_maintenance_start_notification(
1138 self, maintenance_state: MaintenanceState, notification: MaintenanceNotification
1139 ):
1140 add_debug_log_for_notification(self.connection, notification)
1141
1142 if _should_skip_connection_timeout_update(
1143 self.connection.maintenance_state, self.config
1144 ):
1145 return
1146
1147 self.connection.maintenance_state = maintenance_state
1148 self.connection.set_tmp_settings(
1149 tmp_relaxed_timeout=self.config.relaxed_timeout
1150 )
1151 # extend the timeout for all created connections
1152 self.connection.update_current_socket_timeout(self.config.relaxed_timeout)
1153 if isinstance(notification, OSSNodeMigratingNotification):
1154 # add the notification id to the set of processed start maint notifications
1155 # this is used to skip the unrelaxing of the timeouts if we have received more than
1156 # one start notification before the final end notification
1157 self.connection.add_maint_start_notification(notification.id)
1158
1159 maint_notification = _get_maintenance_notification_name(notification)
1160 record_connection_relaxed_timeout(
1161 connection_name=self._get_pool_name(),
1162 maint_notification=maint_notification,
1163 relaxed=True,
1164 )
1165
1166 _dispatch_maintenance_event(
1167 self.config,
1168 MaintenanceStartedEvent(
1169 connection_pool=self._get_pool(),
1170 connection=self.connection,
1171 state=maintenance_state,
1172 notification=notification,
1173 config=self.config,
1174 ),
1175 )
1176
1177 def handle_maintenance_completed_notification(self, **kwargs: Any) -> None:
1178 # Only reset timeouts if state is not MOVING and relaxed timeouts are enabled
1179 if _should_skip_connection_timeout_update(
1180 self.connection.maintenance_state, self.config
1181 ):
1182 return
1183
1184 # A completion can reach a connection whose start went to another one,
1185 # or none at all; only a connection that was relaxed has a start event
1186 # to pair the completion with
1187 was_relaxed = self.connection.maintenance_state == MaintenanceState.MAINTENANCE
1188
1189 notification = None
1190 if kwargs.get("notification"):
1191 notification = kwargs["notification"]
1192 add_debug_log_for_notification(
1193 self.connection, notification if notification else "MAINTENANCE_COMPLETED"
1194 )
1195 self.connection.reset_tmp_settings(reset_relaxed_timeout=True)
1196 # Maintenance completed - reset the connection
1197 # timeouts by providing -1 as the relaxed timeout
1198 self.connection.update_current_socket_timeout(-1)
1199 self.connection.maintenance_state = MaintenanceState.NONE
1200 # reset the sets that keep track of received start maint
1201 # notifications and skipped end maint notifications
1202 self.connection.reset_received_notifications()
1203
1204 if notification:
1205 maint_notification = _get_maintenance_notification_name(notification)
1206 record_connection_relaxed_timeout(
1207 connection_name=self._get_pool_name(),
1208 maint_notification=maint_notification,
1209 relaxed=False,
1210 )
1211
1212 if was_relaxed:
1213 _dispatch_maintenance_event(
1214 self.config,
1215 MaintenanceCompletedEvent(
1216 connection_pool=self._get_pool(),
1217 connection=self.connection,
1218 state=MaintenanceState.MAINTENANCE,
1219 notification=notification,
1220 config=self.config,
1221 ),
1222 )
1223
1224
1225class OSSMaintNotificationsHandler:
1226 def __init__(
1227 self,
1228 cluster_client: "MaintNotificationsAbstractRedisCluster",
1229 config: MaintNotificationsConfig,
1230 ) -> None:
1231 self.cluster_client = cluster_client
1232 self.config = config
1233 self._processed_notifications = set()
1234 self._in_progress = set()
1235 self._lock = threading.RLock()
1236
1237 def remove_expired_notifications(self):
1238 with self._lock:
1239 for notification in tuple(self._processed_notifications):
1240 if notification.is_expired():
1241 self._processed_notifications.remove(notification)
1242
1243 def handle_notification(self, notification: MaintenanceNotification):
1244 if isinstance(notification, OSSNodeMigratedNotification):
1245 self.handle_oss_maintenance_completed_notification(notification)
1246 else:
1247 logger.error(f"Unhandled notification type: {notification}")
1248
1249 def handle_oss_maintenance_completed_notification(
1250 self, notification: OSSNodeMigratedNotification
1251 ):
1252 self.remove_expired_notifications()
1253
1254 with self._lock:
1255 if (
1256 notification in self._in_progress
1257 or notification in self._processed_notifications
1258 ):
1259 # we are already handling this notification or it has already been processed
1260 # we should skip in_progress notification since when we reinitialize the cluster
1261 # we execute a CLUSTER SLOTS command that can use a different connection
1262 # that has also has the notification and we don't want to
1263 # process the same notification twice
1264 #
1265 # This cheap pre-check runs under _lock alone, before the
1266 # topology refresh lock below. The server delivers the same
1267 # SMIGRATED on every connection, so the vast majority of
1268 # arrivals land here; making them wait for an in-flight CLUSTER
1269 # SLOTS round trip just to discard a notification would stall
1270 # arbitrary command threads inside read_response.
1271 return
1272
1273 # Lock order: _initialization_lock BEFORE _lock. initialize() below runs a
1274 # CLUSTER SLOTS round trip while holding _initialization_lock, and the
1275 # response can carry another SMIGRATED push that is handled inline on that
1276 # thread and needs _lock - so a thread holding _lock must never wait for
1277 # _initialization_lock. See the _initialization_lock comment in
1278 # NodesManager.__init__ for the full ordering rule.
1279 with self.cluster_client.nodes_manager._initialization_lock, self._lock:
1280 if (
1281 notification in self._in_progress
1282 or notification in self._processed_notifications
1283 ):
1284 # Re-check now that both locks are held: another thread may have
1285 # handled the notification while we waited for the refresh lock.
1286 return
1287
1288 if logger.isEnabledFor(logging.DEBUG):
1289 logger.debug(f"Handling SMIGRATED notification: {notification}")
1290 self._in_progress.add(notification)
1291
1292 try:
1293 # Extract the information about the src and destination nodes that are affected
1294 # by the maintenance. nodes_to_slots_mapping structure:
1295 # {
1296 # "src_host:port": [
1297 # {"dest_host:port": "slot_range"},
1298 # ...
1299 # ],
1300 # ...
1301 # }
1302 additional_startup_nodes_info = []
1303 affected_nodes = set()
1304 for (
1305 src_address,
1306 dest_mappings,
1307 ) in notification.nodes_to_slots_mapping.items():
1308 src_host, src_port = src_address.rsplit(":", 1)
1309 src_node = self.cluster_client.nodes_manager.get_node(
1310 host=src_host, port=int(src_port)
1311 )
1312 if src_node is not None:
1313 affected_nodes.add(src_node)
1314
1315 for dest_mapping in dest_mappings:
1316 for dest_address in dest_mapping.keys():
1317 dest_host, dest_port = dest_address.rsplit(":", 1)
1318 additional_startup_nodes_info.append(
1319 (dest_host, int(dest_port))
1320 )
1321
1322 # Updates the cluster slots cache with the new slots mapping
1323 # This will also update the nodes cache with the new nodes mapping
1324 self.cluster_client.nodes_manager.initialize(
1325 disconnect_startup_nodes_pools=False,
1326 additional_startup_nodes_info=additional_startup_nodes_info,
1327 )
1328
1329 all_nodes = set(affected_nodes)
1330 all_nodes = all_nodes.union(
1331 self.cluster_client.nodes_manager.nodes_cache.values()
1332 )
1333
1334 for current_node in all_nodes:
1335 if current_node.redis_connection is None:
1336 continue
1337 with current_node.redis_connection.connection_pool._lock:
1338 handoff_recorded = False
1339 if current_node in affected_nodes:
1340 # mark for reconnect all in use connections to the node - this will force them to
1341 # disconnect after they complete their current commands
1342 # Some of them might be used by sub sub and we don't know which ones - so we disconnect
1343 # all in flight connections after they are done with current command execution
1344 for conn in current_node.redis_connection.connection_pool._get_in_use_connections():
1345 add_debug_log_for_notification(
1346 conn, "SMIGRATED - mark for reconnect"
1347 )
1348 conn.mark_for_reconnect()
1349
1350 record_connection_handoff(
1351 pool_name=get_pool_name(
1352 current_node.redis_connection.connection_pool
1353 )
1354 )
1355 handoff_recorded = True
1356 else:
1357 if logger.isEnabledFor(logging.DEBUG):
1358 logger.debug(
1359 f"SMIGRATED: Node {current_node.name} not affected by maintenance, "
1360 f"skipping mark for reconnect"
1361 )
1362
1363 if (
1364 current_node
1365 not in self.cluster_client.nodes_manager.nodes_cache.values()
1366 ):
1367 # disconnect all free connections to the node - this node will be dropped
1368 # from the cluster, so we don't need to revert the timeouts
1369 for conn in current_node.redis_connection.connection_pool._get_free_connections():
1370 conn.disconnect()
1371
1372 # Only record handoff if not already recorded for this node
1373 if not handoff_recorded:
1374 record_connection_handoff(
1375 pool_name=get_pool_name(
1376 current_node.redis_connection.connection_pool
1377 )
1378 )
1379
1380 # mark the notification as processed
1381 self._processed_notifications.add(notification)
1382 finally:
1383 # Release the in-progress reservation. On success the notification
1384 # is also in _processed_notifications (so it won't be re-handled);
1385 # on failure it is not, allowing a later redelivery to retry.
1386 self._in_progress.discard(notification)