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

270 lines
10 KiB
Python

import time
from concurrent.futures import ThreadPoolExecutor
from unittest import mock
import pytest
from mlflow.entities.span import Span
from mlflow.tracing.export.uc_table import DatabricksUCTableSpanExporter
from mlflow.tracing.trace_manager import InMemoryTraceManager
from mlflow.tracing.utils import generate_trace_id_v4
from tests.tracing.helper import (
create_mock_otel_span,
create_test_trace_info_with_uc_table,
)
@pytest.mark.parametrize("is_async", [True, False], ids=["async", "sync"])
def test_export_spans_to_uc_table(is_async, monkeypatch):
monkeypatch.setenv("MLFLOW_ENABLE_ASYNC_TRACE_LOGGING", str(is_async))
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "1") # no batch
trace_manager = InMemoryTraceManager.get_instance()
mock_client = mock.MagicMock()
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock_client
otel_span = create_mock_otel_span(trace_id=12345, span_id=1)
trace_id = generate_trace_id_v4(otel_span, "catalog.schema")
span = Span(otel_span)
# Create trace info with UC table
trace_info = create_test_trace_info_with_uc_table(trace_id, "catalog", "schema")
trace_manager.register_trace(otel_span.context.trace_id, trace_info)
trace_manager.register_span(span)
# Export the span
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.spans",
):
exporter.export([otel_span])
if is_async:
# For async tests, we need to flush the specific exporter's queue
exporter._async_queue.flush(terminate=True)
# Verify UC table logging was called
mock_client.log_spans.assert_called_once()
args = mock_client.log_spans.call_args
assert args[0][0] == "catalog.schema.spans"
assert len(args[0][1]) == 1
assert isinstance(args[0][1][0], Span)
assert args[0][1][0].to_dict() == span.to_dict()
def test_log_trace_no_upload_data_for_uc_schema():
mock_client = mock.MagicMock()
# Mock trace info with UC schema
mock_trace_info = mock.MagicMock()
mock_trace_info.trace_location.uc_schema = mock.MagicMock()
mock_client.start_trace.return_value = mock_trace_info
mock_trace = mock.MagicMock()
mock_trace.info = mock.MagicMock()
mock_prompts = []
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock_client
with mock.patch("mlflow.tracing.utils.add_size_stats_to_trace_metadata"):
exporter._log_trace(mock_trace, mock_prompts)
# Verify start_trace was called but _upload_trace_data was not
mock_client.start_trace.assert_called_once_with(mock_trace.info)
mock_client._upload_trace_data.assert_not_called()
def test_log_trace_no_log_spans_if_no_uc_schema():
mock_client = mock.MagicMock()
# Mock trace info without UC schema
mock_trace_info = mock.MagicMock()
mock_trace_info.trace_location.uc_schema = None
mock_client.start_trace.return_value = mock_trace_info
mock_trace = mock.MagicMock()
mock_trace.info = mock.MagicMock()
mock_trace.data = mock.MagicMock()
mock_prompts = []
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock_client
with mock.patch("mlflow.tracing.utils.add_size_stats_to_trace_metadata"):
exporter._log_trace(mock_trace, mock_prompts)
# Verify both start_trace and _upload_trace_data were called
mock_client.start_trace.assert_called_once_with(mock_trace.info)
mock_client.log_spans.assert_not_called()
def test_export_spans_batch_max_size(monkeypatch):
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "5")
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_INTERVAL_MILLIS", "10000")
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock.MagicMock()
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.spans",
):
exporter._export_spans_incrementally([
create_mock_otel_span(trace_id=12345, span_id=1),
create_mock_otel_span(trace_id=12345, span_id=2),
create_mock_otel_span(trace_id=12345, span_id=3),
create_mock_otel_span(trace_id=12345, span_id=4),
])
exporter._client.log_spans.assert_not_called()
exporter._export_spans_incrementally([create_mock_otel_span(trace_id=12345, span_id=5)])
# NB: There can be a tiny delay once the batch becomes full and the worker thread
# is interrupted by the threading event and activate the async queue. Flush has to
# happen after the activation.
time.sleep(1)
exporter._async_queue.flush()
exporter._client.log_spans.assert_called_once()
location, spans = exporter._client.log_spans.call_args[0]
assert location == "catalog.schema.spans"
assert len(spans) == 5
assert all(isinstance(span, Span) for span in spans)
def test_export_spans_batch_flush_on_interval(monkeypatch):
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "10")
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_INTERVAL_MILLIS", "1000")
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock.MagicMock()
otel_span = create_mock_otel_span(trace_id=12345, span_id=1)
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.spans",
):
exporter._export_spans_incrementally([otel_span])
# Allow the batcher's interval timer to fire
time.sleep(1.5)
exporter._client.log_spans.assert_called_once()
location, spans = exporter._client.log_spans.call_args[0]
assert location == "catalog.schema.spans"
assert len(spans) == 1
def test_export_spans_batch_shutdown(monkeypatch):
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "10")
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_INTERVAL_MILLIS", "1000")
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock.MagicMock()
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.spans",
):
exporter._export_spans_incrementally([
create_mock_otel_span(trace_id=12345, span_id=1),
create_mock_otel_span(trace_id=12345, span_id=2),
create_mock_otel_span(trace_id=12345, span_id=3),
])
exporter.flush()
exporter._client.log_spans.assert_called_once()
location, spans = exporter._client.log_spans.call_args[0]
assert location == "catalog.schema.spans"
assert len(spans) == 3
def test_export_spans_batch_thread_safety(monkeypatch):
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "10")
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_INTERVAL_MILLIS", "1000")
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock.MagicMock()
def _generate_spans():
exporter._export_spans_incrementally([
create_mock_otel_span(trace_id=12345, span_id=i) for i in range(5)
])
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.spans",
):
with ThreadPoolExecutor(
max_workers=5, thread_name_prefix="test-uc-table-exporter"
) as executor:
futures = [executor.submit(_generate_spans) for _ in range(5)]
for future in futures:
future.result()
exporter.flush()
assert exporter._client.log_spans.call_count == 3
for i in range(3):
location, spans = exporter._client.log_spans.call_args_list[i][0]
assert location == "catalog.schema.spans"
assert len(spans) == 10 if i < 2 else 5, f"Batch {i} had {len(spans)} spans"
def test_export_spans_batch_split_spans_by_location(monkeypatch):
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "10")
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_INTERVAL_MILLIS", "1000")
exporter = DatabricksUCTableSpanExporter()
exporter._client = mock.MagicMock()
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.table_1",
):
exporter._export_spans_incrementally([
create_mock_otel_span(trace_id=12345, span_id=1),
create_mock_otel_span(trace_id=12345, span_id=2),
])
with mock.patch(
"mlflow.tracing.export.uc_table.get_active_spans_table_name",
return_value="catalog.schema.table_2",
):
exporter._export_spans_incrementally([
create_mock_otel_span(trace_id=12345, span_id=3),
create_mock_otel_span(trace_id=12345, span_id=4),
create_mock_otel_span(trace_id=12345, span_id=5),
])
exporter.flush()
assert exporter._client.log_spans.call_count == 2
location, spans = exporter._client.log_spans.call_args_list[0][0]
assert location == "catalog.schema.table_1"
assert len(spans) == 2
location, spans = exporter._client.log_spans.call_args_list[1][0]
assert location == "catalog.schema.table_2"
assert len(spans) == 3
def test_at_exit_callback_registered_in_correct_order(monkeypatch):
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_SPAN_BATCH_SIZE", "10")
monkeypatch.setenv("MLFLOW_ASYNC_TRACE_LOGGING_MAX_INTERVAL_MILLIS", "1000")
# This test validates that the two atexit callbacks are registered in the correct order.
# AsyncTraceExportQueue must be shut down AFTER SpanBatcher. Since atexit executes callbacks in
# last-in-first-out order, we must register the callback for AsyncTraceExportQueue first.
# https://docs.python.org/3/library/atexit.html#atexit.register
with mock.patch("atexit.register") as mock_atexit:
DatabricksUCTableSpanExporter()
assert mock_atexit.call_count == 2
handlers = [call[0][0] for call in mock_atexit.call_args_list]
assert len(handlers) == 2
assert handlers[0].__self__.__class__.__name__ == "AsyncTraceExportQueue"
assert handlers[1].__self__.__class__.__name__ == "SpanBatcher"