188 lines
6.7 KiB
Python
188 lines
6.7 KiB
Python
import atexit
|
|
import logging
|
|
import threading
|
|
import time
|
|
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
|
|
from dataclasses import dataclass
|
|
from queue import Empty, Queue
|
|
from queue import Full as queue_Full
|
|
from typing import Any, Callable, Sequence
|
|
|
|
from mlflow.environment_variables import (
|
|
MLFLOW_ASYNC_TRACE_LOGGING_MAX_QUEUE_SIZE,
|
|
MLFLOW_ASYNC_TRACE_LOGGING_MAX_WORKERS,
|
|
)
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class Task:
|
|
"""A dataclass to represent a simple task."""
|
|
|
|
handler: Callable[..., Any]
|
|
args: Sequence[Any]
|
|
error_msg: str = ""
|
|
|
|
def handle(self) -> None:
|
|
"""Handle the task execution. This method must not raise any exception."""
|
|
try:
|
|
self.handler(*self.args)
|
|
except Exception as e:
|
|
_logger.warning(
|
|
f"{self.error_msg} Error: {e}.",
|
|
exc_info=_logger.isEnabledFor(logging.DEBUG),
|
|
)
|
|
|
|
|
|
class AsyncTraceExportQueue:
|
|
"""A queue-based asynchronous tracing export processor."""
|
|
|
|
def __init__(self):
|
|
self._queue: Queue[Task] = Queue(maxsize=MLFLOW_ASYNC_TRACE_LOGGING_MAX_QUEUE_SIZE.get())
|
|
self._lock = threading.RLock()
|
|
self._max_workers = MLFLOW_ASYNC_TRACE_LOGGING_MAX_WORKERS.get()
|
|
|
|
# Thread event that indicates the queue should stop processing tasks
|
|
self._stop_event = threading.Event()
|
|
self._is_active = False
|
|
self._active_tasks = set()
|
|
self._last_full_queue_warning_time = None
|
|
|
|
atexit.register(self._at_exit_callback)
|
|
|
|
def put(self, task: Task):
|
|
"""Put a new task to the queue for processing."""
|
|
if not self.is_active():
|
|
if self._stop_event.is_set():
|
|
# Queue was terminated via flush(terminate=True); _stop_event will never be
|
|
# cleared, so activating and then waiting would deadlock. Execute synchronously.
|
|
task.handle()
|
|
return
|
|
self.activate()
|
|
|
|
# If stop event is set, wait for the queue to be drained before putting the task
|
|
if self._stop_event.is_set():
|
|
self._stop_event.wait()
|
|
|
|
try:
|
|
# Do not block if the queue is full, it will block the main application
|
|
self._queue.put(task, block=False)
|
|
except queue_Full:
|
|
if self._last_full_queue_warning_time is None or (
|
|
time.time() - self._last_full_queue_warning_time > 30
|
|
):
|
|
_logger.warning(
|
|
"Trace export queue is full, trace will be discarded. "
|
|
"Consider increasing the queue size through "
|
|
"`MLFLOW_ASYNC_TRACE_LOGGING_MAX_QUEUE_SIZE` environment variable or "
|
|
"number of workers through `MLFLOW_ASYNC_TRACE_LOGGING_MAX_WORKERS`"
|
|
" environment variable."
|
|
)
|
|
self._last_full_queue_warning_time = time.time()
|
|
|
|
def _consumer_loop(self) -> None:
|
|
while not self._stop_event.is_set():
|
|
self._dispatch_task()
|
|
|
|
# Drain remaining tasks when stopping
|
|
while not self._queue.empty():
|
|
self._dispatch_task()
|
|
|
|
def _dispatch_task(self) -> None:
|
|
"""Dispatch a task from the queue to the worker thread pool."""
|
|
# NB: Monitor number of active tasks being processed by the workers. If the all
|
|
# workers are busy, wait for one of them to finish before draining a new task
|
|
# from the queue. This is because ThreadPoolExecutor does not have a built-in
|
|
# mechanism to limit the number of pending tasks in the internal queue.
|
|
# This ruins the purpose of having a size bound for self._queue, because the
|
|
# TPE's internal queue can grow indefinitely and potentially run out of memory.
|
|
# Therefore, we should only dispatch a new task when there is a worker available,
|
|
# and pend the new tasks in the self._queue which has a size bound.
|
|
if len(self._active_tasks) >= self._max_workers:
|
|
_, self._active_tasks = wait(self._active_tasks, return_when=FIRST_COMPLETED)
|
|
|
|
try:
|
|
task = self._queue.get(timeout=1)
|
|
except Empty:
|
|
return
|
|
|
|
def _handle(task):
|
|
task.handle()
|
|
self._queue.task_done()
|
|
|
|
try:
|
|
future = self._worker_threadpool.submit(_handle, task)
|
|
self._active_tasks.add(future)
|
|
except Exception as e:
|
|
# In case it fails to submit the task to the worker thread pool
|
|
# such as interpreter shutdown, handle the task in this thread
|
|
_logger.debug(
|
|
f"Failed to submit task to worker thread pool. Error: {e}",
|
|
exc_info=True,
|
|
)
|
|
_handle(task)
|
|
|
|
def activate(self) -> None:
|
|
"""Activate the async queue to accept and handle incoming tasks."""
|
|
with self._lock:
|
|
if self._is_active:
|
|
return
|
|
|
|
self._set_up_threads()
|
|
self._is_active = True
|
|
|
|
def is_active(self) -> bool:
|
|
return self._is_active
|
|
|
|
def _set_up_threads(self) -> None:
|
|
"""Set up the consumer and worker threads."""
|
|
with self._lock:
|
|
self._worker_threadpool = ThreadPoolExecutor(
|
|
max_workers=self._max_workers,
|
|
thread_name_prefix="MlflowTraceLoggingWorker",
|
|
)
|
|
self._consumer_thread = threading.Thread(
|
|
target=self._consumer_loop,
|
|
name="MLflowTraceLoggingConsumer",
|
|
daemon=True,
|
|
)
|
|
self._consumer_thread.start()
|
|
|
|
def _at_exit_callback(self) -> None:
|
|
"""Callback function executed when the program is exiting."""
|
|
if not self.is_active():
|
|
return
|
|
|
|
try:
|
|
_logger.info(
|
|
"Flushing the async trace logging queue before program exit. "
|
|
"This may take a while..."
|
|
)
|
|
self.flush(terminate=True)
|
|
except Exception as e:
|
|
_logger.error(f"Error while finishing trace export requests: {e}")
|
|
|
|
def flush(self, terminate=False) -> None:
|
|
"""
|
|
Flush the async logging queue.
|
|
|
|
Args:
|
|
terminate: If True, shut down the logging threads after flushing.
|
|
"""
|
|
if not self.is_active():
|
|
return
|
|
|
|
self._stop_event.set()
|
|
self._consumer_thread.join()
|
|
|
|
# Wait for all tasks to be processed
|
|
self._queue.join()
|
|
|
|
self._worker_threadpool.shutdown(wait=True)
|
|
self._is_active = False
|
|
# Restart threads to listen to incoming requests after flushing, if not terminating
|
|
if not terminate:
|
|
self._stop_event.clear()
|
|
self.activate()
|