Files
2026-07-13 13:22:34 +08:00

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()