1import asyncio
2import functools
3import logging
4from typing import TYPE_CHECKING, Any, Awaitable, Callable
5
6from redis.asyncio.observability.recorder import (
7 record_connection_handoff,
8 record_connection_relaxed_timeout,
9 record_maint_notification_count,
10)
11from redis.maint_notifications import (
12 MaintenanceNotification,
13 MaintenanceState,
14 MaintNotificationsConfig,
15 NodeMovingNotification,
16 OSSNodeMigratedNotification,
17 OSSNodeMigratingNotification,
18 _get_maintenance_notification_name,
19 _get_maintenance_notification_type,
20 _should_skip_connection_timeout_update,
21)
22from redis.observability.attributes import get_pool_name
23
24if TYPE_CHECKING:
25 from redis.asyncio.cluster import RedisCluster
26 from redis.asyncio.connection import AsyncMaintNotificationsAbstractConnection
27
28logger = logging.getLogger(__name__)
29
30_ScheduledCallback = Callable[..., Awaitable[None]]
31
32
33def _log_task_exception(message: str, task: "asyncio.Task[Any]") -> None:
34 """Task done-callback that surfaces a failed task's exception in the logs.
35
36 Without it, an unhandled exception in a fire-and-forget task is only
37 reported by asyncio as a noisy "Task exception was never retrieved"
38 warning. Retrieving the exception here suppresses that warning and logs a
39 meaningful error instead. Cancellation is expected and left unlogged.
40
41 Bind ``message`` with ``functools.partial`` before passing to
42 ``add_done_callback`` (which supplies ``task``).
43 """
44 try:
45 exc = task.exception()
46 except asyncio.CancelledError:
47 return
48 if exc:
49 logger.error(message, exc_info=exc)
50
51
52def add_debug_log_for_notification(
53 connection: object,
54 notification: str | MaintenanceNotification,
55) -> None:
56 if not logger.isEnabledFor(logging.DEBUG):
57 return
58
59 socket_address = None
60 try:
61 writer = getattr(connection, "_writer", None)
62 socket_name = writer.get_extra_info("sockname") if writer else None
63 socket_address = (
64 socket_name[1] if socket_name and len(socket_name) > 1 else None
65 )
66 except (AttributeError, OSError, TypeError):
67 pass
68
69 resolved_ip = None
70 try:
71 get_resolved_ip = getattr(connection, "get_resolved_ip", None)
72 if callable(get_resolved_ip):
73 resolved_ip = get_resolved_ip()
74 except (AttributeError, OSError):
75 pass
76
77 logger.debug(
78 f"Handling maintenance notification: {notification}, "
79 f"with connection: {connection}, connected to ip {resolved_ip}, "
80 f"local socket port: {socket_address}",
81 )
82
83
84class AsyncMaintNotificationsPoolHandler:
85 def __init__(
86 self,
87 pool: Any,
88 config: MaintNotificationsConfig,
89 ) -> None:
90 self.pool = pool
91 self.config = config
92 self._processed_notifications: set[MaintenanceNotification] = set()
93 self._scheduled_tasks: set[asyncio.Task[None]] = set()
94 self._lock = asyncio.Lock()
95 self.connection: Any | None = None
96
97 def set_connection(
98 self, connection: "AsyncMaintNotificationsAbstractConnection"
99 ) -> None:
100 self.connection = connection
101
102 def get_handler_for_connection(self) -> "AsyncMaintNotificationsPoolHandler":
103 # Copy all data that should be shared between connections, while each
104 # connection gets its own handler instance and current connection state.
105 copy = AsyncMaintNotificationsPoolHandler(self.pool, self.config)
106 copy._processed_notifications = self._processed_notifications
107 copy._scheduled_tasks = self._scheduled_tasks
108 copy._lock = self._lock
109 copy.connection = None
110 return copy
111
112 async def remove_expired_notifications(self) -> None:
113 async with self._lock:
114 for notification in tuple(self._processed_notifications):
115 if notification.is_expired():
116 self._processed_notifications.remove(notification)
117
118 async def handle_notification(self, notification: MaintenanceNotification) -> None:
119 await self.remove_expired_notifications()
120
121 if isinstance(notification, NodeMovingNotification):
122 await self.handle_node_moving_notification(notification)
123 else:
124 logger.error(f"Unhandled notification type: {notification}")
125
126 async def handle_node_moving_notification(
127 self, notification: NodeMovingNotification
128 ) -> None:
129 if (
130 not self.config.proactive_reconnect
131 and not self.config.is_relaxed_timeouts_enabled()
132 ):
133 return
134
135 async with self._lock:
136 if notification in self._processed_notifications:
137 # nothing to do in the connection pool handling
138 # the notification has already been handled or is expired
139 # just return
140 return
141 if logger.isEnabledFor(logging.DEBUG):
142 logger.debug(
143 f"Handling node MOVING notification: {notification}, "
144 f"with connection: {self.connection}, connected to ip "
145 f"{self.connection.get_resolved_ip() if self.connection else None}"
146 )
147 # Get the current connected address - if any
148 # This is the address that is being moved
149 # and we need to handle only connections
150 # connected to the same address
151 moving_address_src = (
152 self.connection.getpeername() if self.connection else None
153 )
154
155 # The async pool owns the active/free connection collections and
156 # asyncio.Lock is not reentrant, so the whole MOVING pool mutation
157 # has to be one pool-owned atomic operation. The handler still owns
158 # the notification policy and passes the already-decided inputs.
159 await self.pool.apply_moving_notification(
160 notification=notification,
161 config=self.config,
162 moving_address_src=moving_address_src,
163 run_proactive_reconnect=(
164 self.config.proactive_reconnect
165 and notification.new_node_host is not None
166 ),
167 )
168
169 if self.config.proactive_reconnect and notification.new_node_host is None:
170 self._schedule(
171 notification.ttl / 2,
172 self.run_proactive_reconnect,
173 moving_address_src,
174 )
175
176 self._schedule(
177 notification.ttl,
178 self.handle_node_moved_notification,
179 notification,
180 )
181
182 await record_connection_handoff(
183 pool_name=get_pool_name(self.pool),
184 )
185
186 self._processed_notifications.add(notification)
187
188 async def run_proactive_reconnect(
189 self, moving_address_src: str | None = None
190 ) -> None:
191 """
192 Run proactive reconnect for the pool.
193 Active connections are marked for reconnect after they complete the current command.
194 Inactive connections are disconnected and will be connected on next use.
195 """
196 async with self._lock:
197 # This delayed reconnect must be atomic for the same reason as the
198 # initial MOVING mutation: a connection can move between active and
199 # free lists while another task is acquiring or releasing it.
200 await self.pool.run_proactive_reconnect(
201 moving_address_src=moving_address_src,
202 )
203
204 async def handle_node_moved_notification(
205 self, notification: NodeMovingNotification
206 ) -> None:
207 """
208 Handle the cleanup after a node moving notification expires.
209 """
210 notification_hash = hash(notification)
211
212 async with self._lock:
213 if logger.isEnabledFor(logging.DEBUG):
214 logger.debug(
215 f"Reverting temporary changes related to notification: {notification}, "
216 f"with connection: {self.connection}, connected to ip "
217 f"{self.connection.get_resolved_ip() if self.connection else None}"
218 )
219 reset_relaxed_timeout = self.config.is_relaxed_timeouts_enabled()
220 reset_host_address = self.config.proactive_reconnect
221
222 # Cleanup has to reset future connection kwargs and existing
223 # matching connections together under the pool lock. Splitting it
224 # lets an acquire/release interleave and leaves stale MOVING state.
225 await self.pool.cleanup_moving_notification(
226 notification_hash=notification_hash,
227 reset_relaxed_timeout=reset_relaxed_timeout,
228 reset_host_address=reset_host_address,
229 )
230
231 def _schedule(
232 self,
233 delay: float,
234 callback: _ScheduledCallback,
235 *args: Any,
236 ) -> None:
237 # Record the absolute deadline now so that any lag between create_task
238 # and the task's first execution slice does not push the fire time out.
239 deadline = asyncio.get_running_loop().time() + delay
240 task = asyncio.create_task(self._run_after(deadline, callback, *args))
241 self._scheduled_tasks.add(task)
242 task.add_done_callback(self._scheduled_tasks.discard)
243 task.add_done_callback(
244 functools.partial(
245 _log_task_exception,
246 "Error handling scheduled maintenance notification",
247 )
248 )
249
250 async def _run_after(
251 self,
252 deadline: float,
253 callback: _ScheduledCallback,
254 *args: Any,
255 ) -> None:
256 remaining = deadline - asyncio.get_running_loop().time()
257 if remaining > 0:
258 await asyncio.sleep(remaining)
259 await callback(*args)
260
261 async def cancel_scheduled_tasks(self) -> None:
262 if not self._scheduled_tasks:
263 return
264 tasks = tuple(self._scheduled_tasks)
265 for task in tasks:
266 task.cancel()
267 await asyncio.gather(*tasks, return_exceptions=True)
268
269
270class AsyncMaintNotificationsConnectionHandler:
271 def __init__(
272 self,
273 connection: "AsyncMaintNotificationsAbstractConnection",
274 config: MaintNotificationsConfig,
275 ) -> None:
276 self.connection = connection
277 self.config = config
278
279 def _get_pool_name(self) -> str:
280 """
281 Get the pool name from the connection's pool handler.
282 Falls back to connection representation if pool is not available.
283 """
284 pool_handler = getattr(
285 self.connection, "_maint_notifications_pool_handler", None
286 )
287 if pool_handler and getattr(pool_handler, "pool", None):
288 return get_pool_name(pool_handler.pool)
289 # Fallback for standalone connections without a pool
290 return repr(self.connection)
291
292 async def handle_notification(self, notification: MaintenanceNotification) -> None:
293 # 1 for start, 0 for end notification type, None for unknown.
294 notification_type = _get_maintenance_notification_type(notification)
295 maint_notification = _get_maintenance_notification_name(notification)
296
297 await record_maint_notification_count(
298 server_address=self.connection.host,
299 server_port=self.connection.port,
300 network_peer_address=self.connection.host,
301 network_peer_port=self.connection.port,
302 maint_notification=maint_notification,
303 )
304
305 if notification_type is None:
306 logger.error(f"Unhandled notification type: {notification}")
307 return
308
309 if notification_type:
310 await self.handle_maintenance_start_notification(
311 MaintenanceState.MAINTENANCE, notification
312 )
313 else:
314 await self.handle_maintenance_completed_notification(
315 notification=notification
316 )
317
318 async def handle_maintenance_start_notification(
319 self,
320 maintenance_state: MaintenanceState,
321 notification: MaintenanceNotification,
322 ) -> None:
323 add_debug_log_for_notification(self.connection, notification)
324
325 if _should_skip_connection_timeout_update(
326 self.connection.maintenance_state, self.config
327 ):
328 return
329
330 self.connection.maintenance_state = maintenance_state
331 self.connection.set_tmp_settings(
332 tmp_relaxed_timeout=self.config.relaxed_timeout
333 )
334 self.connection.update_current_socket_timeout(self.config.relaxed_timeout)
335 if isinstance(notification, OSSNodeMigratingNotification):
336 # add the notification id to the set of processed start maint notifications
337 # this is used to skip the unrelaxing of the timeouts if we have received more than
338 # one start notification before the the final end notification
339 self.connection.add_maint_start_notification(notification.id)
340
341 maint_notification = _get_maintenance_notification_name(notification)
342 await record_connection_relaxed_timeout(
343 connection_name=self._get_pool_name(),
344 maint_notification=maint_notification,
345 relaxed=True,
346 )
347
348 async def handle_maintenance_completed_notification(self, **kwargs: Any) -> None:
349 # Only reset timeouts if state is not MOVING and relaxed timeouts are enabled
350 if _should_skip_connection_timeout_update(
351 self.connection.maintenance_state, self.config
352 ):
353 return
354
355 notification = None
356 if kwargs.get("notification"):
357 notification = kwargs["notification"]
358 add_debug_log_for_notification(
359 self.connection, notification if notification else "MAINTENANCE_COMPLETED"
360 )
361 self.connection.reset_tmp_settings(reset_relaxed_timeout=True)
362 # Maintenance completed - reset the connection
363 # timeouts by providing -1 as the relaxed timeout
364 self.connection.update_current_socket_timeout(-1)
365 self.connection.maintenance_state = MaintenanceState.NONE
366 # reset the sets that keep track of received start maint
367 # notifications and skipped end maint notifications
368 self.connection.reset_received_notifications()
369
370 if notification:
371 maint_notification = _get_maintenance_notification_name(notification)
372 await record_connection_relaxed_timeout(
373 connection_name=self._get_pool_name(),
374 maint_notification=maint_notification,
375 relaxed=False,
376 )
377
378
379class AsyncOSSMaintNotificationsHandler:
380 """
381 Cluster-wide handler for OSS (open-source) cluster maintenance notifications.
382
383 Reacts to SMIGRATED (slot migration completed) push notifications and
384 triggers topology re-initialization via nodes_manager.initialize().
385
386 Lock discipline: _lock is held across the whole pool mutation, including
387 await initialize(), so the topology refresh and the subsequent connection
388 marking/disconnect happen as one atomic operation — releasing the lock
389 mid-mutation would let other tasks observe partial state.
390
391 Re-entrancy is not a deadlock risk here even though asyncio.Lock is
392 non-reentrant: initialize() may dispatch commands whose responses carry new
393 push notifications, but handle_notification schedules the actual handling as
394 a separate background task rather than calling into it inline, so the
395 re-entrant arrival never tries to re-acquire the lock on this call stack.
396 The cheap _in_progress/_processed dedup that gates that scheduling runs
397 without the lock — those sets are only mutated from the single event loop.
398 """
399
400 def __init__(
401 self,
402 cluster_client: "RedisCluster",
403 config: MaintNotificationsConfig,
404 ) -> None:
405 self.cluster_client = cluster_client
406 self.config = config
407 self._processed_notifications: set[MaintenanceNotification] = set()
408 self._in_progress: set[MaintenanceNotification] = set()
409 self._lock = asyncio.Lock()
410 self._background_tasks: set[asyncio.Task] = set()
411
412 async def remove_expired_notifications(self) -> None:
413 async with self._lock:
414 for n in tuple(self._processed_notifications):
415 if n.is_expired():
416 self._processed_notifications.remove(n)
417
418 async def handle_notification(self, notification: MaintenanceNotification) -> None:
419 # Synchronous pre-dedup BEFORE scheduling a task.
420 #
421 # The same SMIGRATED notification is delivered by the server on every
422 # connection, so under load this callback fires many times for the same
423 # notification. Without this guard we would schedule one background task
424 # per arrival - a flood of tasks that each acquire the lock and dedup,
425 # saturating the single event loop. Reserving the notification in
426 # _in_progress here (and skipping if already in-progress/processed) caps
427 # it at exactly ONE handling task per unique notification.
428 #
429 # These sets are only mutated from the single event loop, so this
430 # check-and-add is race-free without holding the lock.
431 if (
432 notification in self._in_progress
433 or notification in self._processed_notifications
434 ):
435 return
436 self._in_progress.add(notification)
437
438 # Schedule as a background task so the parser's read path is not blocked.
439 # This also breaks the inline call chain that would otherwise deadlock:
440 # initialize() dispatches commands whose responses may carry more push
441 # notifications; as a separate task it can run while this one awaits.
442 #
443 # If scheduling the task (or wiring its callbacks) fails, release the
444 # in-progress reservation made above. Otherwise the notification would be
445 # stuck in _in_progress forever - the dedup guard would skip it on every
446 # future arrival, and _do_handle_notification's finally (which normally
447 # clears it) never runs because the task was never started.
448 try:
449 task = asyncio.get_running_loop().create_task(
450 self._do_handle_notification(notification)
451 )
452 self._background_tasks.add(task)
453 task.add_done_callback(self._background_tasks.discard)
454 task.add_done_callback(
455 functools.partial(
456 _log_task_exception,
457 "Error handling maintenance notification background task",
458 )
459 )
460 except Exception:
461 self._in_progress.discard(notification)
462 raise
463
464 async def _do_handle_notification(
465 self, notification: MaintenanceNotification
466 ) -> None:
467 try:
468 if isinstance(notification, OSSNodeMigratedNotification):
469 await self.handle_oss_maintenance_completed_notification(notification)
470 else:
471 logger.error(f"Unhandled notification type: {notification}")
472 finally:
473 # Release the in-progress reservation. On success the notification is
474 # also in _processed_notifications (so it won't be re-handled); on
475 # failure it is not, allowing a later retry.
476 self._in_progress.discard(notification)
477
478 async def handle_oss_maintenance_completed_notification(
479 self, notification: OSSNodeMigratedNotification
480 ) -> None:
481 await self.remove_expired_notifications()
482
483 async with self._lock:
484 # handle_notification already reserved this notification in
485 # _in_progress and guaranteed uniqueness; the processed check here is
486 # defensive (e.g. across handler copies sharing the same sets).
487 if notification in self._processed_notifications:
488 return
489 if logger.isEnabledFor(logging.DEBUG):
490 logger.debug(f"Handling SMIGRATED notification: {notification}")
491
492 # Extract the information about the src and destination nodes that are
493 # affected by the maintenance. nodes_to_slots_mapping structure:
494 # {
495 # "src_host:port": [
496 # {"dest_host:port": "slot_range"},
497 # ...
498 # ],
499 # ...
500 # }
501 additional_startup_nodes_info = []
502 affected_nodes = set()
503 for (
504 src_address,
505 dest_mappings,
506 ) in notification.nodes_to_slots_mapping.items():
507 src_host, src_port = src_address.rsplit(":", 1)
508 src_node = self.cluster_client.nodes_manager.get_node(
509 host=src_host, port=int(src_port)
510 )
511 if src_node is not None:
512 affected_nodes.add(src_node)
513 for dest_mapping in dest_mappings:
514 for dest_address in dest_mapping.keys():
515 dest_host, dest_port = dest_address.rsplit(":", 1)
516 additional_startup_nodes_info.append(
517 (dest_host, int(dest_port))
518 )
519 # Updates the cluster slots cache with the new slots mapping
520 # This will also update the nodes cache with the new nodes mapping
521 await self.cluster_client.nodes_manager.initialize(
522 additional_startup_nodes_info=additional_startup_nodes_info,
523 )
524
525 all_nodes = set(affected_nodes)
526 all_nodes = all_nodes.union(
527 self.cluster_client.nodes_manager.nodes_cache.values()
528 )
529 for current_node in all_nodes:
530 handoff_recorded = False
531 if current_node in affected_nodes:
532 # mark for reconnect all in-use connections to the node — this
533 # forces them to disconnect after completing their current command
534 free_set = set(current_node._free)
535 for conn in current_node._connections:
536 if conn not in free_set:
537 add_debug_log_for_notification(
538 conn, "SMIGRATED - mark for reconnect"
539 )
540 conn.mark_for_reconnect()
541 await record_connection_handoff(
542 pool_name=f"{current_node.host}:{current_node.port}"
543 )
544 handoff_recorded = True
545 else:
546 if logger.isEnabledFor(logging.DEBUG):
547 logger.debug(
548 f"SMIGRATED: Node {current_node.name} not affected "
549 f"by maintenance, skipping mark for reconnect"
550 )
551 if (
552 current_node
553 not in self.cluster_client.nodes_manager.nodes_cache.values()
554 ):
555 task = asyncio.get_running_loop().create_task(
556 current_node.disconnect_free_connections()
557 )
558 self._background_tasks.add(task)
559 task.add_done_callback(self._background_tasks.discard)
560 task.add_done_callback(
561 functools.partial(
562 _log_task_exception,
563 "Error disconnecting free connections after "
564 "maintenance notification",
565 )
566 )
567 if not handoff_recorded:
568 await record_connection_handoff(
569 pool_name=f"{current_node.host}:{current_node.port}"
570 )
571
572 self._processed_notifications.add(notification)