Skip to content

Commit 206168f

Browse files
committed
handle graceful shutdown
1 parent 7532878 commit 206168f

7 files changed

Lines changed: 841 additions & 30 deletions

File tree

src/conductor/client/automator/async_task_runner.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def __init__(
110110

111111
# Semaphore will be created in run() within the event loop
112112
self._semaphore = None
113+
self._shutdown = False # Flag to indicate graceful shutdown
113114

114115
async def run(self) -> None:
115116
"""Main async loop - runs continuously in single event loop."""
@@ -150,12 +151,49 @@ async def run(self) -> None:
150151
)
151152

152153
try:
153-
while True:
154+
while not self._shutdown:
154155
await self.run_once()
155156
finally:
156-
# Cleanup async client on exit
157-
if self.async_api_client:
157+
# Cleanup resources on exit
158+
await self._cleanup()
159+
160+
async def stop(self) -> None:
161+
"""Signal the runner to stop gracefully."""
162+
self._shutdown = True
163+
164+
async def _cleanup(self) -> None:
165+
"""Clean up async resources."""
166+
logger.debug("Cleaning up AsyncTaskRunner resources...")
167+
168+
# Cancel any running tasks (EAFP style)
169+
try:
170+
for task in list(self._running_tasks):
171+
if not task.done():
172+
task.cancel()
173+
except AttributeError:
174+
pass # No tasks to cancel
175+
176+
# Close async HTTP client
177+
if self.async_api_client:
178+
try:
158179
await self.async_api_client.close()
180+
logger.debug("Async API client closed successfully")
181+
except (IOError, OSError) as e:
182+
logger.warning(f"Error closing async client: {e}")
183+
184+
# Clear event listeners
185+
self.event_dispatcher = None
186+
187+
logger.debug("AsyncTaskRunner cleanup completed")
188+
189+
async def __aenter__(self):
190+
"""Async context manager entry."""
191+
return self
192+
193+
async def __aexit__(self, exc_type, exc_val, exc_tb):
194+
"""Async context manager exit - ensures cleanup."""
195+
await self._cleanup()
196+
return False # Don't suppress exceptions
159197

160198
async def __async_register_task_definition(self) -> None:
161199
"""
@@ -384,10 +422,8 @@ async def __async_register_task_definition(self) -> None:
384422
async def run_once(self) -> None:
385423
"""Execute one iteration of the polling loop (async version)."""
386424
try:
387-
# Cleanup completed tasks
388-
self.__cleanup_completed_tasks()
389-
390-
# Check if we can accept more tasks
425+
# No need for manual cleanup - tasks remove themselves via add_done_callback
426+
# Just check capacity directly
391427
current_capacity = len(self._running_tasks)
392428
if current_capacity >= self._max_workers:
393429
# At capacity - sleep briefly then return
@@ -435,10 +471,6 @@ async def run_once(self) -> None:
435471
except Exception as e:
436472
logger.error("Error in run_once: %s", traceback.format_exc())
437473

438-
def __cleanup_completed_tasks(self) -> None:
439-
"""Remove completed task futures from tracking set (same as TaskRunner)."""
440-
self._running_tasks = {f for f in self._running_tasks if not f.done()}
441-
442474
async def __async_batch_poll(self, count: int) -> list:
443475
"""Async batch poll for multiple tasks (async version of TaskRunner.__batch_poll_tasks)."""
444476
task_definition_name = self.worker.get_task_definition_name()
@@ -533,7 +565,8 @@ async def __async_batch_poll(self, count: int) -> list:
533565

534566
async def __async_execute_and_update_task(self, task: Task) -> None:
535567
"""Execute task and update result (async version - runs in event loop, not thread pool)."""
536-
# Acquire semaphore to limit concurrency
568+
# Acquire semaphore for entire task lifecycle (execution + update)
569+
# This ensures we never exceed thread_count tasks in any stage of processing
537570
async with self._semaphore:
538571
try:
539572
task_result = await self.__async_execute_task(task)

src/conductor/client/automator/task_handler.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -347,10 +347,22 @@ def __stop_process(self, process: Process):
347347
try:
348348
logger.debug("Terminating process: %s", process.pid)
349349
process.terminate()
350+
# Wait for graceful termination
351+
process.join(timeout=5.0)
352+
if process.is_alive():
353+
logger.warning("Process %s did not terminate within 5 seconds, killing...", process.pid)
354+
process.kill()
355+
process.join(timeout=1.0) # Wait after kill too
356+
if process.is_alive():
357+
logger.error("Process %s could not be killed", process.pid)
350358
except Exception as e:
351359
logger.debug("Failed to terminate process: %s, reason: %s", process.pid, e)
352-
process.kill()
353-
logger.debug("Killed process: %s", process.pid)
360+
try:
361+
process.kill()
362+
process.join(timeout=1.0)
363+
logger.debug("Killed process: %s", process.pid)
364+
except Exception as kill_error:
365+
logger.error("Failed to kill process: %s, reason: %s", process.pid, kill_error)
354366

355367

356368
# Setup centralized logging queue

src/conductor/client/automator/task_runner.py

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import time
66
import traceback
77
from concurrent.futures import ThreadPoolExecutor, as_completed
8+
from typing import List, Optional, Any
89

910
from conductor.client.configuration.configuration import Configuration
1011
from conductor.client.configuration.settings.metrics_settings import MetricsSettings
@@ -90,6 +91,7 @@ def __init__(
9091
self._max_workers = max_workers
9192
self._last_poll_time = 0 # Track last poll to avoid excessive polling when queue is empty
9293
self._consecutive_empty_polls = 0 # Track empty polls to implement backoff
94+
self._shutdown = False # Flag to indicate graceful shutdown
9395

9496
def run(self) -> None:
9597
if self.configuration is not None:
@@ -114,8 +116,53 @@ def run(self) -> None:
114116
self.worker.get_polling_interval_in_seconds()
115117
)
116118

117-
while True:
118-
self.run_once()
119+
try:
120+
while not self._shutdown:
121+
self.run_once()
122+
finally:
123+
# Cleanup resources on exit
124+
self._cleanup()
125+
126+
def stop(self) -> None:
127+
"""Signal the runner to stop gracefully."""
128+
self._shutdown = True
129+
130+
def _cleanup(self) -> None:
131+
"""Clean up resources - called on exit."""
132+
logger.debug("Cleaning up TaskRunner resources...")
133+
134+
# Shutdown ThreadPoolExecutor (EAFP style - more Pythonic)
135+
try:
136+
self._executor.shutdown(wait=True, cancel_futures=True)
137+
logger.debug("ThreadPoolExecutor shut down successfully")
138+
except AttributeError:
139+
pass # No executor to shutdown
140+
except (RuntimeError, ValueError) as e:
141+
logger.warning(f"Error shutting down executor: {e}")
142+
143+
# Close HTTP client (EAFP style)
144+
try:
145+
rest_client = self.task_client.api_client.rest_client
146+
rest_client.close()
147+
logger.debug("HTTP client closed successfully")
148+
except AttributeError:
149+
pass # No client to close or no close method
150+
except (IOError, OSError) as e:
151+
logger.warning(f"Error closing HTTP client: {e}")
152+
153+
# Clear event listeners
154+
self.event_dispatcher = None
155+
156+
logger.debug("TaskRunner cleanup completed")
157+
158+
def __enter__(self):
159+
"""Context manager entry - returns self for 'with' statement usage."""
160+
return self
161+
162+
def __exit__(self, exc_type, exc_val, exc_tb):
163+
"""Context manager exit - ensures cleanup even if exception occurs."""
164+
self._cleanup()
165+
return False # Don't suppress exceptions
119166

120167
def __register_task_definition(self) -> None:
121168
"""
@@ -350,8 +397,11 @@ def run_once(self) -> None:
350397
self.__cleanup_completed_tasks()
351398

352399
# Check if we can accept more tasks (based on thread_count)
353-
# Account for pending async tasks in capacity calculation
354-
pending_async_count = len(getattr(self.worker, '_pending_async_tasks', {}))
400+
# Account for pending async tasks in capacity calculation (thread-safe)
401+
pending_async_count = 0
402+
if hasattr(self.worker, '_pending_tasks_lock') and hasattr(self.worker, '_pending_async_tasks'):
403+
with self.worker._pending_tasks_lock:
404+
pending_async_count = len(self.worker._pending_async_tasks)
355405
current_capacity = len(self._running_tasks) + pending_async_count
356406
if current_capacity >= self._max_workers:
357407
# At capacity - sleep briefly then return to check again
@@ -397,9 +447,11 @@ def run_once(self) -> None:
397447
logger.error("Error in run_once: %s", traceback.format_exc())
398448

399449
def __cleanup_completed_tasks(self) -> None:
400-
"""Remove completed task futures from tracking set"""
401-
# Fast path: use difference_update for better performance
402-
self._running_tasks = {f for f in self._running_tasks if not f.done()}
450+
"""Remove completed task futures from tracking set (thread-safe)"""
451+
# Avoid recreating the set - modify in place to prevent race conditions
452+
completed = [f for f in self._running_tasks if f.done()]
453+
for f in completed:
454+
self._running_tasks.discard(f)
403455

404456
def __check_completed_async_tasks(self) -> None:
405457
"""Check for completed async tasks and update Conductor"""

src/conductor/client/http/api_client.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -765,7 +765,9 @@ def __refresh_auth_token(self) -> None:
765765
return
766766
# Initial token generation - apply backoff if there were previous failures
767767
token = self.__get_new_token(skip_backoff=False)
768-
self.configuration.update_token(token)
768+
# If token is None and auth was disabled (404), skip update
769+
if token is not None or self.configuration.authentication_settings is not None:
770+
self.configuration.update_token(token)
769771

770772
def force_refresh_auth_token(self) -> bool:
771773
"""
@@ -780,6 +782,10 @@ def force_refresh_auth_token(self) -> bool:
780782
if token:
781783
self.configuration.update_token(token)
782784
return True
785+
# Check if auth was disabled during token refresh (404 response)
786+
if self.configuration.authentication_settings is None:
787+
logger.info('Authentication was disabled (no auth endpoint found)')
788+
return False
783789
return False
784790

785791
def __force_refresh_auth_token(self) -> bool:
@@ -857,6 +863,28 @@ def __get_new_token(self, skip_backoff: bool = False) -> str:
857863
)
858864
return None
859865

866+
except rest.ApiException as ae:
867+
# Check if it's a 404 - indicates no authentication endpoint (Conductor OSS)
868+
if ae.is_not_found():
869+
logger.info(
870+
'Authentication endpoint /token not found (404). '
871+
'Running in open mode without authentication (Conductor OSS).'
872+
)
873+
# Disable authentication to prevent future attempts
874+
self.configuration.authentication_settings = None
875+
self.configuration.AUTH_TOKEN = None
876+
# Reset failure counter since this is not a failure
877+
self._token_refresh_failures = 0
878+
return None
879+
else:
880+
# Other API errors
881+
self._token_refresh_failures += 1
882+
logger.error(
883+
f'API error when getting token (attempt {self._token_refresh_failures}): '
884+
f'{ae.status} - {ae.reason}'
885+
)
886+
return None
887+
860888
except Exception as e:
861889
# Other errors (network, etc)
862890
self._token_refresh_failures += 1

src/conductor/client/worker/worker.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,8 @@ def __init__(self,
330330

331331
# Track pending async tasks: {task_id -> (future, task, submit_time)}
332332
self._pending_async_tasks = {}
333+
# Add thread lock for safe concurrent access to _pending_async_tasks
334+
self._pending_tasks_lock = threading.Lock()
333335

334336
def execute(self, task: Task) -> TaskResult:
335337
task_input = {}
@@ -367,15 +369,17 @@ def execute(self, task: Task) -> TaskResult:
367369
# This allows high concurrency for async I/O-bound workloads
368370
future = self._background_loop.submit_coroutine(task_output)
369371

370-
# Store future for later retrieval
372+
# Store future for later retrieval (thread-safe)
371373
submit_time = time.time()
372-
self._pending_async_tasks[task.task_id] = (future, task, submit_time)
374+
with self._pending_tasks_lock:
375+
self._pending_async_tasks[task.task_id] = (future, task, submit_time)
376+
pending_count = len(self._pending_async_tasks)
373377

374378
logger.debug(
375379
"Submitted async task: %s (task_id=%s, pending_count=%d, submit_time=%s)",
376380
task.task_def_name,
377381
task.task_id,
378-
len(self._pending_async_tasks),
382+
pending_count,
379383
submit_time
380384
)
381385

@@ -455,11 +459,16 @@ def check_completed_async_tasks(self) -> list:
455459
completed_results = []
456460
tasks_to_remove = []
457461

458-
pending_count = len(self._pending_async_tasks)
462+
# Create snapshot of pending tasks to avoid iteration during modification
463+
with self._pending_tasks_lock:
464+
tasks_snapshot = list(self._pending_async_tasks.items())
465+
pending_count = len(self._pending_async_tasks)
466+
459467
if pending_count > 0:
460468
logger.debug(f"Checking {pending_count} pending async tasks")
461469

462-
for task_id, (future, task, submit_time) in list(self._pending_async_tasks.items()):
470+
# Process snapshot outside of lock
471+
for task_id, (future, task, submit_time) in tasks_snapshot:
463472
if future.done(): # Non-blocking check
464473
done_time = time.time()
465474
actual_duration = done_time - submit_time
@@ -531,9 +540,11 @@ def check_completed_async_tasks(self) -> list:
531540
completed_results.append((task_id, task_result, submit_time, task))
532541
tasks_to_remove.append(task_id)
533542

534-
# Remove completed tasks
535-
for task_id in tasks_to_remove:
536-
del self._pending_async_tasks[task_id]
543+
# Remove completed tasks (thread-safe)
544+
with self._pending_tasks_lock:
545+
for task_id in tasks_to_remove:
546+
# Use pop to avoid KeyError if task was already removed
547+
self._pending_async_tasks.pop(task_id, None)
537548

538549
return completed_results
539550

0 commit comments

Comments
 (0)