@@ -118,17 +118,18 @@ def __init__(self, rabbit_config: dict | None = None) -> None:
118118 def __del__ (self ):
119119 self .disconnect ()
120120
121- def connect (self ) -> None :
121+ def connect (self , retry_while : Callable [[], bool ] | None = None ) -> bool :
122122 """Create a connection (or reconnect after error).
123123
124- If connection can't be established, try it again indefinitely.
124+ If connection can't be established, try it again indefinitely unless
125+ ``retry_while`` is provided and returns false.
125126 """
126127 if self .connection :
127128 self .connection .close ()
128129 self ._connection_id += 1
129130
130131 attempts = 0
131- while True :
132+ while retry_while is None or retry_while () :
132133 attempts += 1
133134 try :
134135 self .connection = amqpstorm .Connection (** self .conn_params )
@@ -144,15 +145,20 @@ def connect(self) -> None:
144145 channel .confirm_deliveries ()
145146 channel .basic .qos (PREFETCH_COUNT )
146147 self .channel = channel
147- break
148+ return True
148149 except amqpstorm .AMQPError as e :
149150 sleep_time = RECONNECT_DELAYS [min (attempts , len (RECONNECT_DELAYS )) - 1 ]
150151 self .log .error (
151152 f"RabbitMQ connection error (will try to reconnect in { sleep_time } s): { e } "
152153 )
153- time .sleep (sleep_time )
154+ sleep_deadline = time .monotonic () + sleep_time
155+ while time .monotonic () < sleep_deadline :
156+ if retry_while is not None and not retry_while ():
157+ return False
158+ time .sleep (min (0.1 , sleep_deadline - time .monotonic ()))
154159 except KeyboardInterrupt :
155- break
160+ return False
161+ return False
156162
157163 def disconnect (self ) -> None :
158164 if self .connection :
@@ -407,6 +413,8 @@ def __init__(
407413
408414 self ._consuming_thread : threading .Thread | None = None
409415 self ._processing_thread : threading .Thread | None = None
416+ self ._watchdog_recovery_thread : threading .Thread | None = None
417+ self ._stopping = False
410418
411419 # Receive messages into 2 temporary queues
412420 # (max length should be equal to prefetch_count set in RabbitMQReader)
@@ -427,8 +435,9 @@ def start(self) -> None:
427435 if self .running :
428436 raise RuntimeError ("Already running" )
429437
430- if not self .connection :
431- self .connect ()
438+ if not self .connection and not self .connect (retry_while = lambda : not self ._stopping ):
439+ return
440+ self ._stopping = False
432441
433442 self .log .info ("Starting TaskQueueReader" )
434443
@@ -454,14 +463,19 @@ def stop(self, timeout: float | None = None) -> bool:
454463 Returns:
455464 Whether all internal reader threads stopped.
456465 """
466+ self ._stopping = True
467+ deadline_ts = None if timeout is None else time .monotonic () + timeout
468+ recovery_stopped = self ._stop_watchdog_recovery_thread (_remaining_time (deadline_ts ))
457469 if not self .running :
458- raise RuntimeError ("Not running" )
470+ if recovery_stopped :
471+ return True
472+ self .log .error ("TaskQueueReader watchdog recovery did not stop before timeout" )
473+ return False
459474
460475 self .running = False
461- deadline_ts = None if timeout is None else time .monotonic () + timeout
462476 consuming_stopped = self ._stop_consuming_thread (_remaining_time (deadline_ts ))
463477 processing_stopped = self ._stop_processing_thread (_remaining_time (deadline_ts ))
464- stopped = consuming_stopped and processing_stopped
478+ stopped = consuming_stopped and processing_stopped and recovery_stopped
465479 if stopped :
466480 self .log .info ("TaskQueueReader stopped" )
467481 else :
@@ -473,7 +487,7 @@ def reconnect(self) -> None:
473487 self .cache .clear ()
474488 self .cache_pri .clear ()
475489
476- self .connect ()
490+ self .connect (retry_while = lambda : self . running and not self . _stopping )
477491
478492 def check (self ) -> bool :
479493 """
@@ -604,32 +618,93 @@ def _msg_processing_thread_func(self):
604618 self .log .exception ("Error in user callback function. %s: %s" , type (e ), str (e ))
605619 self .log .error ("Original message: %s" , body )
606620
607- def watchdog (self ):
621+ def watchdog (self ) -> bool :
608622 """
609- Check whether both threads are running and perform a reset if not.
623+ Check whether both threads are running and start recovery if not.
610624
611- Register to be called periodically by scheduler.
625+ Register to be called periodically by scheduler. RabbitMQ recovery remains
626+ resilient and may retry indefinitely, but it runs in a daemon thread so it
627+ cannot block scheduler execution or process shutdown.
612628 """
613629 proc = self ._processing_thread is not None and self ._processing_thread .is_alive ()
614630 cons = self ._consuming_thread is not None and self ._consuming_thread .is_alive ()
615631
616- if not proc or not cons :
617- self .log .error (
618- "Dead threads detected, processing=%s, consuming=%s, restarting TaskQueueReader." ,
619- "alive" if proc else "dead" ,
620- "alive" if cons else "dead" ,
621- )
622- self ._stop_consuming_thread ()
623- self ._stop_processing_thread ()
632+ if proc and cons :
633+ return True
634+
635+ self .log .error (
636+ "Dead threads detected, processing=%s, consuming=%s, restarting TaskQueueReader." ,
637+ "alive" if proc else "dead" ,
638+ "alive" if cons else "dead" ,
639+ )
640+ if self ._watchdog_recovery_thread and self ._watchdog_recovery_thread .is_alive ():
641+ self .log .warning ("TaskQueueReader watchdog recovery is already running" )
642+ return False
624643
625- if self .channel is not None :
626- self .channel .close ()
627- self .channel = None
628- self .cache .clear ()
629- self .cache_pri .clear ()
644+ self ._watchdog_recovery_thread = threading .Thread (
645+ target = self ._recover_after_dead_threads ,
646+ name = f"TaskQueueReaderRecovery-{ self .worker_index } " ,
647+ daemon = True ,
648+ )
649+ self ._watchdog_recovery_thread .start ()
650+ return False
651+
652+ def _recover_after_dead_threads (self ) -> None :
653+ consuming_thread = self ._consuming_thread
654+ processing_thread = self ._processing_thread
655+ channel = self .channel
656+ cache = self .cache
657+ cache_pri = self .cache_pri
658+ self .running = False
630659
631- self .connect ()
660+ def recovery_can_continue () -> bool :
661+ return not self ._stopping
662+
663+ try :
664+ if consuming_thread :
665+ if consuming_thread .is_alive ():
666+ with contextlib .suppress (amqpstorm .AMQPError ):
667+ if channel is not None :
668+ channel .stop_consuming ()
669+ consuming_thread .join ()
670+
671+ if processing_thread :
672+ if processing_thread .is_alive ():
673+ self .cache_full .set () # break potential wait() for data
674+ processing_thread .join ()
675+
676+ if channel is not None :
677+ channel .close ()
678+
679+ if not recovery_can_continue ():
680+ return
681+ if self ._consuming_thread is consuming_thread :
682+ self ._consuming_thread = None
683+ if self ._processing_thread is processing_thread :
684+ self ._processing_thread = None
685+ if self .channel is channel :
686+ self .channel = None
687+ if self .cache is cache :
688+ self .cache .clear ()
689+ if self .cache_pri is cache_pri :
690+ self .cache_pri .clear ()
691+
692+ if not self .connect (retry_while = recovery_can_continue ):
693+ return
694+ if not recovery_can_continue ():
695+ self .disconnect ()
696+ return
632697 self .start ()
698+ except Exception :
699+ self .log .exception ("Error while resetting TaskQueueReader" )
700+
701+ def _stop_watchdog_recovery_thread (self , timeout : float | None = None ) -> bool :
702+ if self ._watchdog_recovery_thread :
703+ self ._watchdog_recovery_thread .join (timeout = timeout )
704+ if self ._watchdog_recovery_thread .is_alive ():
705+ return False
706+ self ._watchdog_recovery_thread = None
707+ return True
633708
634709 def _stop_consuming_thread (self , timeout : float | None = None ) -> bool :
635710 if self ._consuming_thread :
0 commit comments