Skip to content

Commit 6e8707b

Browse files
committed
Task queue: bound watchdog reset path
The TaskQueueReader watchdog is a recovery path, not normal shutdown, but it used the same unbounded stop and channel close calls that caused shutdown hangs. A blocked reset can wedge the scheduler thread and prevent further recovery attempts. Run the reset in a daemon helper with a bounded timeout, log and return if it cannot complete, and add a regression test for a blocking channel.close().
1 parent 4c289a3 commit 6e8707b

5 files changed

Lines changed: 252 additions & 43 deletions

File tree

dp3/common/control.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,7 @@ def as_message(self) -> str:
4343
class Control:
4444
"""Class enabling remote control of the platform's internal events."""
4545

46-
def __init__(
47-
self,
48-
platform_config: PlatformConfig,
49-
) -> None:
46+
def __init__(self, platform_config: PlatformConfig) -> None:
5047
self.log = logging.getLogger("Control")
5148
self.action_handlers: dict[ControlAction, Callable] = {}
5249

dp3/task_processing/task_distributor.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -167,9 +167,6 @@ def _force_shutdown(self, alive_workers: int, reader_stopped: bool) -> None:
167167
os._exit(1) # nuke entire process
168168

169169
def _stop_task_queue_reader(self, timeout: float) -> bool:
170-
if not self._task_queue_reader.running:
171-
return True
172-
173170
reader_stopped = False
174171

175172
# TaskQueueReader.stop() is expected to honor its timeout, but run it in

dp3/task_processing/task_queue.py

Lines changed: 104 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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:

dp3/worker.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,11 @@ def main(app_name: str, config_dir: str, process_index: int, verbose: bool) -> i
216216
# Create instances of core components
217217
log.info(f"***** {app_name} worker {process_index} of {num_processes} start *****")
218218

219+
# Lock used to control when the program stops.
220+
daemon_stop_lock = threading.Lock()
221+
daemon_stop_lock.acquire()
222+
clean_stop_requested = threading.Event()
223+
219224
# EventCountLogger
220225
ecl = EventCountLogger(
221226
platform_config.config.get("event_logging.groups"),
@@ -253,11 +258,6 @@ def main(app_name: str, config_dir: str, process_index: int, verbose: bool) -> i
253258
Telemetry(db, platform_config, registrar)
254259
GarbageCollector(db, platform_config, registrar)
255260

256-
# Lock used to control when the program stops.
257-
daemon_stop_lock = threading.Lock()
258-
daemon_stop_lock.acquire()
259-
clean_stop_requested = threading.Event()
260-
261261
# Signal handler releasing the lock on SIGINT or SIGTERM.
262262
def sigint_handler(signum, frame):
263263
log.debug(

0 commit comments

Comments
 (0)