Files
mlflow--mlflow/tests/store/tracking/test_databricks_rest_store.py
2026-07-13 13:22:34 +08:00

2411 lines
88 KiB
Python

import base64
import json
import time
from unittest import mock
import pytest
from google.protobuf.json_format import MessageToDict
from opentelemetry.proto.trace.v1.trace_pb2 import Span as OTelProtoSpan
import mlflow
from mlflow.entities import Span
from mlflow.entities.assessment import (
AssessmentSource,
AssessmentSourceType,
Feedback,
FeedbackValue,
)
from mlflow.entities.trace import Trace
from mlflow.entities.trace_data import TraceData
from mlflow.entities.trace_info import TraceInfo
from mlflow.entities.trace_location import TraceLocation, UCSchemaLocation, UnityCatalog
from mlflow.entities.trace_state import TraceState
from mlflow.entities.trace_status import TraceStatus
from mlflow.environment_variables import (
MLFLOW_ASYNC_TRACE_LOGGING_RETRY_TIMEOUT,
MLFLOW_SQL_WAREHOUSE_AUTO_START,
MLFLOW_TRACING_SQL_WAREHOUSE_ID,
)
from mlflow.exceptions import MlflowException, MlflowNotImplementedException, RestException
from mlflow.protos import databricks_pb2
from mlflow.protos.databricks_pb2 import ENDPOINT_NOT_FOUND
from mlflow.protos.databricks_tracing_pb2 import (
BatchGetTraces,
CreateLocation,
CreateTraceUCStorageLocation,
DeleteTraceTag,
GetLocation,
GetTraceInfo,
LinkExperimentToUCTraceLocation,
LinkTraceLocation,
SetTraceTag,
UnLinkExperimentToUCTraceLocation,
)
from mlflow.protos.databricks_tracing_pb2 import UCSchemaLocation as ProtoUCSchemaLocation
from mlflow.protos.databricks_tracing_pb2 import (
UcTablePrefixLocation as ProtoUcTablePrefixLocation,
)
from mlflow.protos.service_pb2 import DeleteTraceTag as DeleteTraceTagV3
from mlflow.protos.service_pb2 import GetTraceInfoV3, StartTraceV3
from mlflow.protos.service_pb2 import SetTraceTag as SetTraceTagV3
from mlflow.store.tracking.databricks_rest_store import CompositeToken, DatabricksTracingRestStore
from mlflow.store.tracking.rest_store import RestStore
from mlflow.tracing.constant import TRACE_ID_V4_PREFIX
from mlflow.utils.databricks_tracing_utils import assessment_to_proto, trace_to_proto
from mlflow.utils.proto_json_utils import message_to_json
from mlflow.utils.rest_utils import (
_V3_TRACE_REST_API_PATH_PREFIX,
_V4_TRACE_REST_API_PATH_PREFIX,
MlflowHostCreds,
)
@pytest.fixture(autouse=True)
def _disable_sql_warehouse_auto_start(monkeypatch):
"""
Keep tests hermetic: prevent the SQL warehouse auto-start logic from reaching the real
Databricks SDK when MLFLOW_TRACING_SQL_WAREHOUSE_ID is set. Tests that assert on the
auto-start hook patch ``ensure_sql_warehouse_running`` directly, which intercepts the call
regardless of this flag.
"""
monkeypatch.setenv(MLFLOW_SQL_WAREHOUSE_AUTO_START.name, "false")
@pytest.fixture
def sql_warehouse_id(monkeypatch):
wh_id = "test-warehouse"
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, wh_id)
return wh_id
def create_mock_spans(diff_trace_id=False):
otel_span1 = OTelProtoSpan()
otel_span1.name = "span1"
otel_span1.trace_id = b"trace123"
otel_span2 = OTelProtoSpan()
otel_span2.name = "span2"
otel_span2.trace_id = b"trace456" if diff_trace_id else b"trace123"
# Mock spans
mock_span1 = mock.MagicMock(spec=Span)
mock_span1.trace_id = "trace123"
mock_span1.to_otel_proto.return_value = otel_span1
mock_span1._span = mock.MagicMock()
mock_span1._span.resource = None
mock_span2 = mock.MagicMock(spec=Span)
mock_span2.trace_id = "trace456" if diff_trace_id else "trace123"
mock_span2.to_otel_proto.return_value = otel_span2
mock_span2._span = mock.MagicMock()
mock_span2._span.resource = None
return [mock_span1, mock_span2]
def _to_v4_trace(trace: Trace) -> Trace:
trace_location = TraceLocation.from_databricks_uc_schema("catalog", "schema")
trace.info.trace_location = trace_location
trace.info.trace_id = (
f"{TRACE_ID_V4_PREFIX}{trace_location.uc_schema.schema_location}/{trace.info.trace_id}"
)
return trace
def _args(host_creds, endpoint, method, json_body, version, retry_timeout_seconds=None):
res = {
"host_creds": host_creds,
"endpoint": f"/api/{version}/mlflow/{endpoint}",
"method": method,
}
if retry_timeout_seconds is not None:
res["retry_timeout_seconds"] = retry_timeout_seconds
if method == "GET":
res["params"] = json.loads(json_body) if json_body is not None else None
else:
res["json"] = json.loads(json_body) if json_body is not None else None
return res
def _verify_requests(
http_request,
host_creds,
endpoint,
method,
json_body,
version="4.0",
retry_timeout_seconds=None,
):
"""
Verify HTTP requests in tests.
Args:
http_request: The mocked HTTP request object
host_creds: MlflowHostCreds object
endpoint: The endpoint being called (e.g., "traces/123")
method: The HTTP method (e.g., "GET", "POST")
json_body: The request body as a JSON string
version: The version of the API to use (e.g., "2.0", "3.0", "4.0")
retry_timeout_seconds: The retry timeout seconds to use for the request
"""
http_request.assert_any_call(
**(_args(host_creds, endpoint, method, json_body, version, retry_timeout_seconds))
)
def test_create_trace_v4_uc_location(monkeypatch):
monkeypatch.setenv(MLFLOW_ASYNC_TRACE_LOGGING_RETRY_TIMEOUT.name, "1")
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "test-warehouse")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
trace_info = TraceInfo(
trace_id="trace:/catalog.schema/123",
trace_location=TraceLocation.from_databricks_uc_schema("catalog", "schema"),
request_time=123,
execution_duration=10,
state=TraceState.OK,
request_preview="",
response_preview="",
trace_metadata={},
)
# Mock successful v4 response
response = mock.MagicMock()
response.status_code = 200
expected_trace_info = MessageToDict(trace_info.to_proto(), preserving_proto_field_name=True)
# The returned trace_id in proto should be otel_trace_id
expected_trace_info.update({"trace_id": "123"})
response.text = json.dumps(expected_trace_info)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
result = store.start_trace(trace_info)
_verify_requests(
mock_http,
creds,
"traces/catalog.schema/123/info",
"POST",
message_to_json(trace_info.to_proto()),
version="4.0",
retry_timeout_seconds=1,
)
assert result.trace_id == "trace:/catalog.schema/123"
def test_create_trace_experiment_location_fallback_to_v3(monkeypatch):
monkeypatch.setenv(MLFLOW_ASYNC_TRACE_LOGGING_RETRY_TIMEOUT.name, "1")
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "test-warehouse")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
trace_info = TraceInfo(
trace_id="tr-456",
trace_location=TraceLocation.from_experiment_id("456"),
request_time=456,
execution_duration=20,
state=TraceState.OK,
request_preview="preview",
response_preview="response",
trace_metadata={"key": "value"},
)
trace = Trace(info=trace_info, data=TraceData())
v3_response = StartTraceV3.Response(trace=trace.to_proto())
with mock.patch.object(store, "_call_endpoint") as mock_call_endpoint:
mock_call_endpoint.side_effect = [v3_response]
result = store.start_trace(trace_info)
assert mock_call_endpoint.call_count == 1
call_args = mock_call_endpoint.call_args_list[0]
assert call_args[0][0] == StartTraceV3
assert result.trace_id == "tr-456"
def test_get_trace_info(monkeypatch):
with mlflow.start_span(name="test_span_v4") as span:
span.set_inputs({"input": "test_value"})
span.set_outputs({"output": "result"})
trace = mlflow.get_trace(span.trace_id)
trace = _to_v4_trace(trace)
mock_response = GetTraceInfo.Response(trace=trace_to_proto(trace))
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("https://test"))
location = "catalog.schema"
v4_trace_id = f"{TRACE_ID_V4_PREFIX}{location}/{span.trace_id}"
monkeypatch.setenv("MLFLOW_TRACING_SQL_WAREHOUSE_ID", "test-warehouse")
with mock.patch.object(store, "_call_endpoint", return_value=mock_response) as mock_call:
result = store.get_trace_info(v4_trace_id)
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == GetTraceInfo
request_body = call_args[0][1]
request_data = json.loads(request_body)
assert request_data["trace_id"] == span.trace_id
assert request_data["location"] == location
assert request_data["sql_warehouse_id"] == "test-warehouse"
endpoint = call_args[1]["endpoint"]
assert f"/traces/{location}/{span.trace_id}/info" in endpoint
assert isinstance(result, TraceInfo)
assert result.trace_id == trace.info.trace_id
def test_get_trace_info_fallback_to_v3():
with mlflow.start_span(name="test_span_v3") as span:
span.set_inputs({"input": "test_value"})
trace = mlflow.get_trace(span.trace_id)
mock_v3_response = GetTraceInfoV3.Response(trace=trace.to_proto())
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("https://test"))
with mock.patch.object(store, "_call_endpoint", return_value=mock_v3_response) as mock_call:
result = store.get_trace_info(span.trace_id)
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == GetTraceInfoV3
request_body = call_args[0][1]
request_data = json.loads(request_body)
assert request_data["trace_id"] == span.trace_id
assert isinstance(result, TraceInfo)
assert result.trace_id == span.trace_id
def test_get_trace_info_missing_warehouse_id():
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("https://test"))
with mock.patch.object(
RestStore,
"_call_endpoint",
side_effect=RestException(
json={
"error_code": databricks_pb2.ErrorCode.Name(databricks_pb2.INVALID_PARAMETER_VALUE),
"message": "Could not resolve a SQL warehouse ID. Please provide one.",
}
),
):
with pytest.raises(MlflowException, match="SQL warehouse ID is required for "):
store.get_trace_info("trace:/catalog.schema/1234567890")
def test_set_trace_tag():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
location = "catalog.schema"
trace_id = "tr-1234"
request = SetTraceTag(
key="k",
value="v",
)
response.text = "{}"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.set_trace_tag(
trace_id=f"{TRACE_ID_V4_PREFIX}{location}/{trace_id}",
key=request.key,
value=request.value,
)
expected_json = {
"key": request.key,
"value": request.value,
}
mock_http.assert_called_once_with(
host_creds=creds,
endpoint=f"/api/4.0/mlflow/traces/{location}/{trace_id}/tags",
method="PATCH",
json=expected_json,
)
assert res is None
def test_set_trace_tag_fallback():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
trace_id = "tr-1234"
response.text = "{}"
with mock.patch.object(
store, "_call_endpoint", return_value=SetTraceTagV3.Response()
) as mock_call:
result = store.set_trace_tag(
trace_id=trace_id,
key="k",
value="v",
)
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == SetTraceTagV3
request_body = call_args[0][1]
request_data = json.loads(request_body)
assert request_data["key"] == "k"
assert request_data["value"] == "v"
assert result is None
def test_delete_trace_tag(monkeypatch):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
location = "catalog.schema"
trace_id = "tr-1234"
sql_warehouse_id = "warehouse_456"
request = DeleteTraceTag(
trace_id=trace_id,
location_id=location,
key="k",
)
response.text = "{}"
monkeypatch.setenv("MLFLOW_TRACING_SQL_WAREHOUSE_ID", sql_warehouse_id)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.delete_trace_tag(
trace_id=f"{TRACE_ID_V4_PREFIX}{location}/{trace_id}",
key=request.key,
)
mock_http.assert_called_once_with(
host_creds=creds,
endpoint=f"/api/4.0/mlflow/traces/{location}/{trace_id}/tags/{request.key}?sql_warehouse_id={sql_warehouse_id}",
method="DELETE",
json=None,
)
assert res is None
def test_delete_trace_tag_with_special_characters(monkeypatch):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
location = "catalog.schema"
trace_id = "tr-1234"
sql_warehouse_id = "warehouse_456"
key_with_slash = "foo/bar"
response.text = "{}"
monkeypatch.setenv("MLFLOW_TRACING_SQL_WAREHOUSE_ID", sql_warehouse_id)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.delete_trace_tag(
trace_id=f"{TRACE_ID_V4_PREFIX}{location}/{trace_id}",
key=key_with_slash,
)
# Verify that the key is URL-encoded in the endpoint (/ becomes %2F)
mock_http.assert_called_once_with(
host_creds=creds,
endpoint=f"/api/4.0/mlflow/traces/{location}/{trace_id}/tags/foo%2Fbar?sql_warehouse_id={sql_warehouse_id}",
method="DELETE",
json=None,
)
assert res is None
def test_delete_trace_tag_fallback():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
trace_id = "tr-1234"
response.text = "{}"
with mock.patch.object(
store, "_call_endpoint", return_value=DeleteTraceTagV3.Response()
) as mock_call:
result = store.delete_trace_tag(
trace_id=trace_id,
key="k",
)
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == DeleteTraceTagV3
request_body = call_args[0][1]
request_data = json.loads(request_body)
assert request_data["key"] == "k"
assert result is None
@pytest.mark.parametrize("sql_warehouse_id", [None, "warehouse_override"])
def test_batch_get_traces(monkeypatch, sql_warehouse_id):
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "test-warehouse")
with mlflow.start_span(name="test_span_1") as span1:
span1.set_inputs({"input": "test_value_1"})
span1.set_outputs({"output": "result_1"})
with mlflow.start_span(name="test_span_2") as span2:
span2.set_inputs({"input": "test_value_2"})
span2.set_outputs({"output": "result_2"})
trace1 = mlflow.get_trace(span1.trace_id)
trace2 = mlflow.get_trace(span2.trace_id)
# trace obtained from OSS backend is still v3
trace1 = _to_v4_trace(trace1)
trace2 = _to_v4_trace(trace2)
mock_response = BatchGetTraces.Response()
mock_response.traces.extend([trace_to_proto(trace1), trace_to_proto(trace2)])
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("https://test"))
location = "catalog.schema"
trace_ids = [trace1.info.trace_id, trace2.info.trace_id]
with (
mock.patch.object(store, "_call_endpoint", return_value=mock_response) as mock_call,
):
result = store.batch_get_traces(trace_ids, location)
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == BatchGetTraces
request_body = call_args[0][1]
request_data = json.loads(request_body)
assert request_data["sql_warehouse_id"] == "test-warehouse"
# trace_ids in the request payload should be original OTel format
assert request_data["trace_ids"] == [span1.trace_id, span2.trace_id]
endpoint = call_args[1]["endpoint"]
assert endpoint == f"{_V4_TRACE_REST_API_PATH_PREFIX}/{location}/batchGet"
assert isinstance(result, list)
assert len(result) == 2
assert all(isinstance(trace, Trace) for trace in result)
assert result[0].info.trace_id == trace1.info.trace_id
assert result[1].info.trace_id == trace2.info.trace_id
def test_search_traces_uc_schema(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "test-warehouse")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"name": "operations/op1",
"done": True,
"response": {
"trace_infos": [
{
# REST API uses raw otel id as trace_id
"trace_id": "1234",
"trace_location": {
"type": "UC_SCHEMA",
"uc_schema": {"catalog_name": "catalog", "schema_name": "schema"},
},
"request_time": "1970-01-01T00:00:00.123Z",
"execution_duration_ms": 456,
"state": "OK",
"trace_metadata": {"key": "value"},
"tags": {"k": "v"},
}
],
"next_page_token": "token",
},
})
filter_string = "state = 'OK'"
max_results = 50
order_by = ["request_time ASC", "execution_duration_ms DESC"]
locations = ["catalog.schema"]
page_token = "12345abcde"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
trace_infos, token = store.search_traces(
filter_string=filter_string,
max_results=max_results,
order_by=order_by,
locations=locations,
page_token=page_token,
)
# V4 long-running endpoint should be called for UC schema locations
assert mock_http.call_count == 1
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running"
json_body = call_args["json"]
assert "locations" in json_body
assert len(json_body["locations"]) == 1
assert json_body["locations"][0]["uc_schema"]["catalog_name"] == "catalog"
assert json_body["locations"][0]["uc_schema"]["schema_name"] == "schema"
assert json_body["filter"] == filter_string
assert json_body["max_results"] == max_results
assert json_body["order_by"] == order_by
assert json_body["page_token"] == page_token
assert json_body["sql_warehouse_id"] == "test-warehouse"
assert len(trace_infos) == 1
assert isinstance(trace_infos[0], TraceInfo)
assert trace_infos[0].trace_id == "trace:/catalog.schema/1234"
assert trace_infos[0].trace_location.uc_schema.catalog_name == "catalog"
assert trace_infos[0].trace_location.uc_schema.schema_name == "schema"
assert trace_infos[0].request_time == 123
assert trace_infos[0].state == TraceStatus.OK.to_state()
assert trace_infos[0].tags == {"k": "v"}
assert trace_infos[0].trace_metadata == {"key": "value"}
assert token == "token"
@pytest.mark.parametrize(
"exception",
[
# Workspace where SearchTracesV4 is not supported yet
RestException(
json={
"error_code": databricks_pb2.ErrorCode.Name(databricks_pb2.ENDPOINT_NOT_FOUND),
"message": "Not found",
}
),
# V4 endpoint does not support searching by experiment ID (yet)
RestException(
json={
"error_code": databricks_pb2.ErrorCode.Name(databricks_pb2.INVALID_PARAMETER_VALUE),
"message": "MLFLOW_EXPERIMENT locations not yet supported",
}
),
],
)
def test_search_traces_experiment_id(exception):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"traces": [
{
"trace_id": "tr-1234",
"trace_location": {
"type": "MLFLOW_EXPERIMENT",
"mlflow_experiment": {"experiment_id": "1"},
},
"request_time": "1970-01-01T00:00:00.123Z",
"execution_duration_ms": 456,
"state": "OK",
"trace_metadata": {"key": "value"},
"tags": {"k": "v"},
}
],
"next_page_token": "token",
})
filter_string = "state = 'OK'"
page_token = "12345abcde"
locations = ["1"]
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
# v4 call -> exception, v3 call -> response
mock_http.side_effect = [exception, response]
trace_infos, token = store.search_traces(
filter_string=filter_string,
page_token=page_token,
locations=locations,
)
# MLflow first tries V4 long-running endpoint, then falls back to V3
assert mock_http.call_count == 2
first_call_args = mock_http.call_args_list[0][1]
assert first_call_args["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running"
json_body = first_call_args["json"]
assert "locations" in json_body
assert len(json_body["locations"]) == 1
assert json_body["locations"][0]["mlflow_experiment"]["experiment_id"] == "1"
assert json_body["filter"] == filter_string
assert json_body["max_results"] == 100
second_call_args = mock_http.call_args_list[1][1]
assert second_call_args["endpoint"] == f"{_V3_TRACE_REST_API_PATH_PREFIX}/search"
json_body = second_call_args["json"]
assert len(json_body["locations"]) == 1
assert json_body["locations"][0]["mlflow_experiment"]["experiment_id"] == "1"
assert json_body["filter"] == filter_string
assert json_body["max_results"] == 100
assert len(trace_infos) == 1
assert isinstance(trace_infos[0], TraceInfo)
assert trace_infos[0].trace_id == "tr-1234"
assert trace_infos[0].experiment_id == "1"
assert trace_infos[0].request_time == 123
assert trace_infos[0].state == TraceStatus.OK.to_state()
assert trace_infos[0].tags == {"k": "v"}
assert trace_infos[0].trace_metadata == {"key": "value"}
assert token == "token"
@pytest.mark.parametrize(
"exception",
[
# Workspace where SearchTracesV4 is not supported yet
RestException(
json={
"error_code": databricks_pb2.ErrorCode.Name(databricks_pb2.ENDPOINT_NOT_FOUND),
"message": "Not found",
}
),
# V4 endpoint does not support searching by experiment ID (yet)
RestException(
json={
"error_code": databricks_pb2.ErrorCode.Name(databricks_pb2.INVALID_PARAMETER_VALUE),
"message": "MLFLOW_EXPERIMENT locations not yet supported",
}
),
],
)
def test_search_traces_with_mixed_locations(exception):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
expected_error_message = (
"Searching traces in UC tables is not supported yet."
if exception.error_code == databricks_pb2.ErrorCode.Name(databricks_pb2.ENDPOINT_NOT_FOUND)
else "The `locations` parameter cannot contain both MLflow experiment and UC schema "
)
with mock.patch("mlflow.utils.rest_utils.http_request", side_effect=exception) as mock_http:
with pytest.raises(MlflowException, match=expected_error_message):
store.search_traces(
filter_string="state = 'OK'",
locations=["1", "catalog.schema"],
)
# V4 long-running endpoint should be called first. No fallback to V3 because location
# includes UC schema.
mock_http.assert_called_once()
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running"
json_body = call_args["json"]
assert "locations" in json_body
assert len(json_body["locations"]) == 2
assert json_body["locations"][0]["mlflow_experiment"]["experiment_id"] == "1"
assert json_body["locations"][1]["uc_schema"]["catalog_name"] == "catalog"
assert json_body["locations"][1]["uc_schema"]["schema_name"] == "schema"
def test_search_traces_does_not_fallback_when_uc_schemas_are_specified():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
def mock_http_request(*args, **kwargs):
if kwargs.get("endpoint") == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running":
raise MlflowException("V4 endpoint not supported", error_code=ENDPOINT_NOT_FOUND)
return mock.MagicMock()
with mock.patch("mlflow.utils.rest_utils.http_request", side_effect=mock_http_request):
with pytest.raises(
MlflowException,
match="Searching traces in UC tables is not supported yet.",
):
store.search_traces(locations=["catalog.schema"])
def test_search_traces_non_fallback_errors():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
mock_http.side_effect = MlflowException("Random error")
with pytest.raises(MlflowException, match="Random error"):
store.search_traces(locations=["catalog.schema"])
def test_search_traces_experiment_ids_deprecated():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
# Test that using experiment_ids raises error saying it's deprecated
with pytest.raises(
MlflowException,
match="experiment_ids.*deprecated.*use.*locations",
):
store.search_traces(
experiment_ids=["123"],
)
def test_search_traces_with_missing_location():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(MlflowException, match="location.*must be specified"):
store.search_traces()
with pytest.raises(MlflowException, match="location.*must be specified"):
store.search_traces(locations=[])
def test_search_traces_with_invalid_location():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(MlflowException, match="Invalid location type:"):
store.search_traces(locations=["catalog.schema.prefix.extra"])
def _operation_response(trace_infos=None, next_page_token=None, done=True, name="operations/op1"):
body = {"name": name, "done": done}
if trace_infos is not None or next_page_token is not None:
response = {}
if trace_infos is not None:
response["trace_infos"] = trace_infos
if next_page_token is not None:
response["next_page_token"] = next_page_token
body["response"] = response
mock_response = mock.MagicMock()
mock_response.status_code = 200
mock_response.text = json.dumps(body)
return mock_response
def _sample_trace_info_json(trace_id="tr-1234", experiment_id="1"):
return {
"trace_id": trace_id,
"trace_location": {
"type": "MLFLOW_EXPERIMENT",
"mlflow_experiment": {"experiment_id": experiment_id},
},
"request_time": "1970-01-01T00:00:00.123Z",
"execution_duration_ms": 456,
"state": "OK",
"trace_metadata": {"key": "value"},
"tags": {"k": "v"},
}
def test_search_traces_long_running_immediate_success():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
initial = _operation_response(
trace_infos=[_sample_trace_info_json()],
next_page_token="next-token",
)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=initial) as mock_http:
trace_infos, token = store.search_traces(locations=["1"])
assert mock_http.call_count == 1
call_args = mock_http.call_args_list[0][1]
assert call_args["method"] == "POST"
assert call_args["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running"
assert len(trace_infos) == 1
assert trace_infos[0].trace_id == "tr-1234"
assert token == "next-token"
def test_search_traces_long_running_polls_then_succeeds(monkeypatch):
monkeypatch.setattr("time.sleep", lambda _s: None)
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
running_resp = mock.MagicMock()
running_resp.status_code = 200
running_resp.text = json.dumps({
"name": "operations/op1",
"done": False,
"metadata": {"state": "RUNNING"},
})
final_resp = _operation_response(trace_infos=[_sample_trace_info_json()])
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
mock_http.side_effect = [running_resp, running_resp, final_resp]
trace_infos, token = store.search_traces(locations=["1"])
assert mock_http.call_count == 3
calls = mock_http.call_args_list
assert calls[0][1]["method"] == "POST"
assert calls[0][1]["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running"
for poll in calls[1:]:
assert poll[1]["method"] == "GET"
assert (
poll[1]["endpoint"]
== f"{_V4_TRACE_REST_API_PATH_PREFIX}/search/operations/operations/op1"
)
assert len(trace_infos) == 1
assert token is None
def test_search_traces_long_running_failure_raises(monkeypatch):
monkeypatch.setattr("time.sleep", lambda _s: None)
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
failed_resp = mock.MagicMock()
failed_resp.status_code = 200
failed_resp.text = json.dumps({
"name": "operations/op1",
"done": True,
"error": {"error_code": "INTERNAL_ERROR", "message": "boom"},
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=failed_resp):
with pytest.raises(MlflowException, match="boom"):
store.search_traces(locations=["1"])
def test_search_traces_long_running_preserves_request_fields(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "wh-42")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = _operation_response(trace_infos=[_sample_trace_info_json()])
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.search_traces(
filter_string="state = 'OK'",
max_results=50,
order_by=["request_time DESC"],
locations=["catalog.schema"],
page_token="page-1",
)
body = mock_http.call_args_list[0][1]["json"]
assert body["filter"] == "state = 'OK'"
assert body["max_results"] == 50
assert body["order_by"] == ["request_time DESC"]
assert body["page_token"] == "page-1"
assert body["sql_warehouse_id"] == "wh-42"
assert body["locations"][0]["uc_schema"]["catalog_name"] == "catalog"
assert body["locations"][0]["uc_schema"]["schema_name"] == "schema"
def test_get_trace_location_v5():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_proto = GetLocation.Response(
uc_table_prefix=ProtoUcTablePrefixLocation(
catalog_name="catalog",
schema_name="schema",
table_prefix="prefix",
),
)
with mock.patch.object(store, "_call_endpoint", return_value=response_proto) as mock_call:
location = store.get_trace_location("loc-123")
call_args = mock_call.call_args
assert call_args[0][0] == GetLocation
assert call_args[1]["endpoint"] == "/api/5.0/mlflow/tracing/locations/loc-123"
assert location.catalog_name == "catalog"
assert location.schema_name == "schema"
assert location.table_prefix == "prefix"
def test_create_or_get_trace_location_v5_with_table_prefix(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "warehouse-1")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_proto = CreateLocation.Response(
uc_table_prefix=ProtoUcTablePrefixLocation(
catalog_name="catalog",
schema_name="schema",
table_prefix="prefix",
),
)
with mock.patch.object(store, "_call_endpoint", return_value=response_proto) as mock_call:
location = store.create_or_get_trace_location(
UnityCatalog(catalog_name="catalog", schema_name="schema", table_prefix="prefix")
)
call_args = mock_call.call_args
assert call_args[0][0] == CreateLocation
assert call_args[1]["endpoint"] == "/api/5.0/mlflow/tracing/locations"
request_payload = json.loads(call_args[0][1])
assert request_payload["sql_warehouse_id"] == "warehouse-1"
assert request_payload["uc_table_prefix"]["catalog_name"] == "catalog"
assert request_payload["uc_table_prefix"]["schema_name"] == "schema"
assert request_payload["uc_table_prefix"]["table_prefix"] == "prefix"
assert location.catalog_name == "catalog"
assert location.schema_name == "schema"
assert location.table_prefix == "prefix"
def test_link_trace_location_v5_with_table_prefix():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_proto = LinkTraceLocation.Response()
with mock.patch.object(store, "_call_endpoint", return_value=response_proto) as mock_call:
store.link_trace_location(
experiment_id="exp-123",
location=UnityCatalog(
catalog_name="catalog", schema_name="schema", table_prefix="prefix"
),
)
call_args = mock_call.call_args
assert call_args[0][0] == LinkTraceLocation
assert call_args[1]["endpoint"] == "/api/5.0/mlflow/experiments/exp-123/trace-location:link"
request_payload = json.loads(call_args[0][1])
assert request_payload["experiment_id"] == "exp-123"
assert request_payload["uc_table_prefix"]["catalog_name"] == "catalog"
assert request_payload["uc_table_prefix"]["schema_name"] == "schema"
assert request_payload["uc_table_prefix"]["table_prefix"] == "prefix"
def test_search_unified_traces(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "test-warehouse")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
# Format the response (using TraceInfo format for online path)
response.text = json.dumps({
"traces": [
{
"request_id": "tr-1234",
"experiment_id": "1234",
"timestamp_ms": 123,
"execution_time_ms": 456,
"status": "OK",
"tags": [
{"key": "k", "value": "v"},
],
"request_metadata": [
{"key": "key", "value": "value"},
],
}
],
"next_page_token": "token",
})
# Parameters for search_traces
experiment_ids = ["1234"]
filter_string = "status = 'OK'"
max_results = 10
order_by = ["timestamp_ms DESC"]
page_token = "12345abcde"
model_id = "model123"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
trace_infos, token = store.search_traces(
locations=experiment_ids,
filter_string=filter_string,
max_results=max_results,
order_by=order_by,
page_token=page_token,
model_id=model_id,
)
# Verify the correct endpoint was called
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == "/api/2.0/mlflow/unified-traces"
# Verify the correct trace info objects were returned
assert len(trace_infos) == 1
assert isinstance(trace_infos[0], TraceInfo)
assert trace_infos[0].trace_id == "tr-1234"
assert trace_infos[0].experiment_id == "1234"
assert trace_infos[0].request_time == 123
# V3's state maps to V2's status
assert trace_infos[0].state == TraceStatus.OK.to_state()
assert trace_infos[0].tags == {"k": "v"}
assert trace_infos[0].trace_metadata == {"key": "value"}
assert token == "token"
def test_set_experiment_trace_location():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
experiment_id = "123"
uc_schema = UCSchemaLocation(catalog_name="test_catalog", schema_name="test_schema")
sql_warehouse_id = "test-warehouse-id"
# Mock response for CreateTraceUCStorageLocation
create_location_response = mock.MagicMock()
create_location_response.uc_schema = ProtoUCSchemaLocation(
catalog_name="test_catalog",
schema_name="test_schema",
otel_spans_table_name="test_spans",
otel_logs_table_name="test_logs",
)
# Mock response for LinkExperimentToUCTraceLocation
link_response = mock.MagicMock()
link_response.status_code = 200
link_response.text = "{}"
with mock.patch.object(store, "_call_endpoint") as mock_call:
mock_call.side_effect = [create_location_response, link_response]
result = store.set_experiment_trace_location(
location=uc_schema,
experiment_id=experiment_id,
sql_warehouse_id=sql_warehouse_id,
)
assert mock_call.call_count == 2
# Verify CreateTraceUCStorageLocation call
first_call = mock_call.call_args_list[0]
assert first_call[0][0] == CreateTraceUCStorageLocation
create_request_body = json.loads(first_call[0][1])
assert create_request_body["uc_schema"]["catalog_name"] == "test_catalog"
assert create_request_body["uc_schema"]["schema_name"] == "test_schema"
assert create_request_body["sql_warehouse_id"] == sql_warehouse_id
assert first_call[1]["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/location"
# Verify LinkExperimentToUCTraceLocation call
second_call = mock_call.call_args_list[1]
assert second_call[0][0] == LinkExperimentToUCTraceLocation
link_request_body = json.loads(second_call[0][1])
assert link_request_body["experiment_id"] == experiment_id
assert link_request_body["uc_schema"]["catalog_name"] == "test_catalog"
assert link_request_body["uc_schema"]["schema_name"] == "test_schema"
assert link_request_body["uc_schema"]["otel_spans_table_name"] == "test_spans"
assert link_request_body["uc_schema"]["otel_logs_table_name"] == "test_logs"
assert (
second_call[1]["endpoint"]
== f"{_V4_TRACE_REST_API_PATH_PREFIX}/{experiment_id}/link-location"
)
assert isinstance(result, UCSchemaLocation)
assert result.catalog_name == "test_catalog"
assert result.schema_name == "test_schema"
assert result.full_otel_spans_table_name == "test_catalog.test_schema.test_spans"
assert result.full_otel_logs_table_name == "test_catalog.test_schema.test_logs"
def test_set_experiment_trace_location_with_existing_location():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
experiment_id = "123"
uc_schema = UCSchemaLocation(catalog_name="test_catalog", schema_name="test_schema")
sql_warehouse_id = "test-warehouse-id"
create_location_response = MlflowException(
"Location already exists", error_code=databricks_pb2.ALREADY_EXISTS
)
# Mock response for LinkExperimentToUCTraceLocation
link_response = mock.MagicMock()
link_response.status_code = 200
link_response.text = "{}"
with mock.patch.object(store, "_call_endpoint") as mock_call:
mock_call.side_effect = [create_location_response, link_response]
result = store.set_experiment_trace_location(
location=uc_schema,
experiment_id=experiment_id,
sql_warehouse_id=sql_warehouse_id,
)
assert mock_call.call_count == 2
# Verify CreateTraceUCStorageLocation call
first_call = mock_call.call_args_list[0]
assert first_call[0][0] == CreateTraceUCStorageLocation
create_request_body = json.loads(first_call[0][1])
assert create_request_body["uc_schema"]["catalog_name"] == "test_catalog"
assert create_request_body["uc_schema"]["schema_name"] == "test_schema"
assert create_request_body["sql_warehouse_id"] == sql_warehouse_id
assert first_call[1]["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/location"
# Verify LinkExperimentToUCTraceLocation call
second_call = mock_call.call_args_list[1]
assert second_call[0][0] == LinkExperimentToUCTraceLocation
link_request_body = json.loads(second_call[0][1])
assert link_request_body["experiment_id"] == experiment_id
assert link_request_body["uc_schema"]["catalog_name"] == "test_catalog"
assert link_request_body["uc_schema"]["schema_name"] == "test_schema"
assert (
second_call[1]["endpoint"]
== f"{_V4_TRACE_REST_API_PATH_PREFIX}/{experiment_id}/link-location"
)
assert isinstance(result, UCSchemaLocation)
assert result.catalog_name == "test_catalog"
assert result.schema_name == "test_schema"
def test_unset_experiment_trace_location_with_uc_schema():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
experiment_id = "123"
response = mock.MagicMock()
response.status_code = 200
response.text = "{}"
with mock.patch.object(store, "_call_endpoint", return_value=response) as mock_call:
store.unset_experiment_trace_location(
experiment_id=experiment_id,
location=UCSchemaLocation(catalog_name="test_catalog", schema_name="test_schema"),
)
mock_call.assert_called_once()
call_args = mock_call.call_args
assert call_args[0][0] == UnLinkExperimentToUCTraceLocation
request_body = json.loads(call_args[0][1])
assert request_body["experiment_id"] == experiment_id
assert request_body["uc_schema"]["catalog_name"] == "test_catalog"
assert request_body["uc_schema"]["schema_name"] == "test_schema"
expected_endpoint = f"{_V4_TRACE_REST_API_PATH_PREFIX}/{experiment_id}/unlink-location"
assert call_args[1]["endpoint"] == expected_endpoint
def test_log_spans_to_uc_table_empty_spans():
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("http://localhost"))
result = store.log_spans("catalog.schema.table", [], tracking_uri="databricks")
assert result == []
@pytest.mark.parametrize("diff_trace_id", [True, False])
def test_log_spans_to_uc_table_success(diff_trace_id):
# Mock configuration
mock_config = mock.MagicMock()
mock_config.authenticate.return_value = {"Authorization": "Bearer token"}
spans = create_mock_spans(diff_trace_id)
# Mock HTTP response
mock_response = mock.MagicMock()
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("http://localhost"))
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.verify_rest_response"
) as mock_verify,
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request", return_value=mock_response
) as mock_http_request,
mock.patch(
"mlflow.store.tracking.databricks_rest_store.get_databricks_workspace_client_config",
return_value=mock_config,
) as mock_get_config,
):
# Execute
store.log_spans("catalog.schema.spans", spans, tracking_uri="databricks")
# Verify calls
mock_get_config.assert_called_once_with("databricks")
mock_http_request.assert_called_once()
mock_verify.assert_called_once_with(mock_response, "/api/2.0/otel/v1/traces")
# Verify HTTP request details
call_kwargs = mock_http_request.call_args
assert call_kwargs[1]["method"] == "POST"
assert call_kwargs[1]["endpoint"] == "/api/2.0/otel/v1/traces"
assert "Content-Type" in call_kwargs[1]["extra_headers"]
assert call_kwargs[1]["extra_headers"]["Content-Type"] == "application/x-protobuf"
assert "X-Databricks-UC-Table-Name" in call_kwargs[1]["extra_headers"]
assert call_kwargs[1]["extra_headers"]["X-Databricks-UC-Table-Name"] == "catalog.schema.spans"
def test_log_spans_to_uc_table_config_error():
mock_span = mock.MagicMock(spec=Span, trace_id="trace123")
spans = [mock_span]
store = DatabricksTracingRestStore(lambda: MlflowHostCreds("http://localhost"))
with mock.patch(
"mlflow.store.tracking.databricks_rest_store.get_databricks_workspace_client_config",
side_effect=Exception("Config failed"),
):
with pytest.raises(MlflowException, match="Failed to log spans to UC table"):
store.log_spans("catalog.schema.spans", spans, tracking_uri="databricks")
def test_create_assessment(sql_warehouse_id):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"assessment_id": "1234",
"assessment_name": "assessment_name",
"trace_identifier": {
"uc_schema": {
"catalog_name": "catalog",
"schema_name": "schema",
},
"trace_id": "1234",
},
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-20T05:47:23Z",
"feedback": {"value": True},
"rationale": "rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
})
feedback = Feedback(
trace_id="trace:/catalog.schema/1234",
name="assessment_name",
value=True,
source=AssessmentSource(
source_type=AssessmentSourceType.LLM_JUDGE, source_id="gpt-4o-mini"
),
create_time_ms=int(time.time() * 1000),
last_update_time_ms=int(time.time() * 1000),
rationale="rationale",
metadata={"model": "gpt-4o-mini"},
span_id=None,
)
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.create_assessment(assessment=feedback)
_verify_requests(
mock_http,
creds,
f"traces/catalog.schema/1234/assessments?sql_warehouse_id={sql_warehouse_id}",
"POST",
message_to_json(assessment_to_proto(feedback)),
version="4.0",
)
assert isinstance(res, Feedback)
assert res.assessment_id is not None
assert res.value == feedback.value
def test_get_assessment(sql_warehouse_id):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
trace_id = "trace:/catalog.schema/1234"
response.text = json.dumps({
"assessment_id": "1234",
"assessment_name": "assessment_name",
"trace_id": trace_id,
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-20T05:47:23Z",
"feedback": {"value": True},
"rationale": "rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
})
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.get_assessment(
trace_id=trace_id,
assessment_id="1234",
)
_verify_requests(
mock_http,
creds,
f"traces/catalog.schema/1234/assessments/1234?sql_warehouse_id={sql_warehouse_id}",
"GET",
json_body=None,
version="4.0",
)
assert isinstance(res, Feedback)
assert res.assessment_id == "1234"
assert res.value is True
def test_update_assessment(sql_warehouse_id):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
trace_id = "trace:/catalog.schema/1234"
response.text = json.dumps({
"assessment_id": "1234",
"assessment_name": "updated_assessment_name",
"trace_location": {
"type": "UC_SCHEMA",
"uc_schema": {
"catalog_name": "catalog",
"schema_name": "schema",
},
},
"trace_id": "1234",
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-20T05:47:23Z",
"feedback": {"value": False},
"rationale": "updated_rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
})
request = {
"assessment_id": "1234",
"trace_location": {
"type": "UC_SCHEMA",
"uc_schema": {
"catalog_name": "catalog",
"schema_name": "schema",
"otel_spans_table_name": "mlflow_experiment_trace_otel_spans",
"otel_logs_table_name": "mlflow_experiment_trace_otel_logs",
},
},
"trace_id": "1234",
"feedback": {"value": False},
"rationale": "updated_rationale",
"metadata": {"model": "gpt-4o-mini"},
}
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.update_assessment(
trace_id=trace_id,
assessment_id="1234",
feedback=FeedbackValue(value=False),
rationale="updated_rationale",
metadata={"model": "gpt-4o-mini"},
)
_verify_requests(
mock_http,
creds,
f"traces/catalog.schema/1234/assessments/1234?sql_warehouse_id={sql_warehouse_id}&update_mask=feedback,rationale,metadata",
"PATCH",
json.dumps(request),
version="4.0",
)
assert isinstance(res, Feedback)
assert res.assessment_id == "1234"
assert res.value is False
assert res.rationale == "updated_rationale"
def test_update_assessment_uc_table_prefix(sql_warehouse_id):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
trace_id = "trace:/catalog.schema.prefix/1234"
response.text = json.dumps({
"assessment_id": "1234",
"assessment_name": "updated_assessment_name",
"trace_location": {
"type": "UC_TABLE_PREFIX",
"uc_table_prefix": {
"catalog_name": "catalog",
"schema_name": "schema",
"table_prefix": "prefix",
},
},
"trace_id": "1234",
"source": {
"source_type": "LLM_JUDGE",
"source_id": "gpt-4o-mini",
},
"create_time": "2025-02-20T05:47:23Z",
"last_update_time": "2025-02-20T05:47:23Z",
"feedback": {"value": False},
"rationale": "updated_rationale",
"metadata": {"model": "gpt-4o-mini"},
"error": None,
"span_id": None,
})
request = {
"assessment_id": "1234",
"trace_location": {
"type": "UC_TABLE_PREFIX",
"uc_table_prefix": {
"catalog_name": "catalog",
"schema_name": "schema",
"table_prefix": "prefix",
},
},
"trace_id": "1234",
"feedback": {"value": False},
"rationale": "updated_rationale",
"metadata": {"model": "gpt-4o-mini"},
}
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
res = store.update_assessment(
trace_id=trace_id,
assessment_id="1234",
feedback=FeedbackValue(value=False),
rationale="updated_rationale",
metadata={"model": "gpt-4o-mini"},
)
_verify_requests(
mock_http,
creds,
f"traces/catalog.schema.prefix/1234/assessments/1234?sql_warehouse_id={sql_warehouse_id}&update_mask=feedback,rationale,metadata",
"PATCH",
json.dumps(request),
version="4.0",
)
assert isinstance(res, Feedback)
assert res.assessment_id == "1234"
assert res.value is False
assert res.rationale == "updated_rationale"
def test_search_traces_uc_table_prefix(monkeypatch):
monkeypatch.setenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, "test-warehouse")
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({
"name": "operations/op1",
"done": True,
"response": {
"trace_infos": [
{
"trace_id": "1234",
"trace_location": {
"type": "UC_TABLE_PREFIX",
"uc_table_prefix": {
"catalog_name": "catalog",
"schema_name": "schema",
"table_prefix": "prefix",
},
},
"request_time": "1970-01-01T00:00:00.123Z",
"execution_duration_ms": 456,
"state": "OK",
"trace_metadata": {"key": "value"},
"tags": {"k": "v"},
}
],
"next_page_token": "token",
},
})
locations = ["catalog.schema.prefix"]
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
trace_infos, token = store.search_traces(
locations=locations,
)
assert mock_http.call_count == 1
call_args = mock_http.call_args[1]
assert call_args["endpoint"] == f"{_V4_TRACE_REST_API_PATH_PREFIX}/search-long-running"
json_body = call_args["json"]
assert "locations" in json_body
assert len(json_body["locations"]) == 1
assert json_body["locations"][0]["uc_table_prefix"]["catalog_name"] == "catalog"
assert json_body["locations"][0]["uc_table_prefix"]["schema_name"] == "schema"
assert json_body["locations"][0]["uc_table_prefix"]["table_prefix"] == "prefix"
assert json_body["sql_warehouse_id"] == "test-warehouse"
assert len(trace_infos) == 1
assert isinstance(trace_infos[0], TraceInfo)
assert trace_infos[0].trace_id == "trace:/catalog.schema.prefix/1234"
assert trace_infos[0].trace_location.uc_table_prefix.catalog_name == "catalog"
assert trace_infos[0].trace_location.uc_table_prefix.schema_name == "schema"
assert trace_infos[0].trace_location.uc_table_prefix.table_prefix == "prefix"
assert token == "token"
def test_delete_assessment(sql_warehouse_id):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
trace_id = "trace:/catalog.schema/1234"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.delete_assessment(
trace_id=trace_id,
assessment_id="1234",
)
_verify_requests(
mock_http,
creds,
f"traces/catalog.schema/1234/assessments/1234?sql_warehouse_id={sql_warehouse_id}",
"DELETE",
json_body=None,
version="4.0",
)
def test_link_traces_to_run_with_v4_trace_ids_uses_batch_v4_endpoint():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
location = "catalog.schema"
trace_ids = [
f"{TRACE_ID_V4_PREFIX}{location}/trace123",
f"{TRACE_ID_V4_PREFIX}{location}/trace456",
]
run_id = "run_abc"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.link_traces_to_run(trace_ids=trace_ids, run_id=run_id)
expected_json = {
"location_id": location,
"trace_ids": ["trace123", "trace456"],
"run_id": run_id,
}
mock_http.assert_called_once_with(
host_creds=creds,
endpoint=f"/api/4.0/mlflow/traces/{location}/link-to-run/batchCreate",
method="POST",
json=expected_json,
)
def test_link_traces_to_run_with_v3_trace_ids_uses_v3_endpoint():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
trace_ids = ["tr-123", "tr-456"]
run_id = "run_abc"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.link_traces_to_run(trace_ids=trace_ids, run_id=run_id)
expected_json = {
"trace_ids": trace_ids,
"run_id": run_id,
}
mock_http.assert_called_once_with(
host_creds=creds,
endpoint="/api/2.0/mlflow/traces/link-to-run",
method="POST",
json=expected_json,
)
def test_link_traces_to_run_with_mixed_v3_v4_trace_ids_handles_both():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
location = "catalog.schema"
v3_trace_id = "tr-123"
v4_trace_id = f"{TRACE_ID_V4_PREFIX}{location}/trace456"
trace_ids = [v3_trace_id, v4_trace_id]
run_id = "run_abc"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.link_traces_to_run(trace_ids=trace_ids, run_id=run_id)
# Should make 2 separate calls: one for V3 and one for V4
assert mock_http.call_count == 2
# Verify V3 call
v3_call = next(
call for call in mock_http.call_args_list if "2.0" in call.kwargs["endpoint"]
)
assert v3_call.kwargs["endpoint"] == "/api/2.0/mlflow/traces/link-to-run"
assert v3_call.kwargs["json"]["trace_ids"] == [v3_trace_id]
assert v3_call.kwargs["json"]["run_id"] == run_id
# Verify V4 call
v4_call = next(
call for call in mock_http.call_args_list if "4.0" in call.kwargs["endpoint"]
)
expected_v4_endpoint = f"/api/4.0/mlflow/traces/{location}/link-to-run/batchCreate"
assert v4_call.kwargs["endpoint"] == expected_v4_endpoint
assert v4_call.kwargs["json"]["trace_ids"] == ["trace456"]
assert v4_call.kwargs["json"]["run_id"] == run_id
def test_link_traces_to_run_with_different_locations_groups_by_location():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
location1 = "catalog1.schema1"
location2 = "catalog2.schema2"
trace_ids = [
f"{TRACE_ID_V4_PREFIX}{location1}/trace123",
f"{TRACE_ID_V4_PREFIX}{location2}/trace456",
f"{TRACE_ID_V4_PREFIX}{location1}/trace789",
]
run_id = "run_abc"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.link_traces_to_run(trace_ids=trace_ids, run_id=run_id)
# Should make 2 separate batch calls, one for each location
assert mock_http.call_count == 2
# Verify calls were made for both locations
calls = mock_http.call_args_list
call_endpoints = {call.kwargs["endpoint"] for call in calls}
expected_endpoints = {
f"/api/4.0/mlflow/traces/{location1}/link-to-run/batchCreate",
f"/api/4.0/mlflow/traces/{location2}/link-to-run/batchCreate",
}
assert call_endpoints == expected_endpoints
# Verify the trace IDs were grouped correctly
for call in calls:
endpoint = call.kwargs["endpoint"]
json_body = call.kwargs["json"]
if location1 in endpoint:
assert set(json_body["trace_ids"]) == {"trace123", "trace789"}
elif location2 in endpoint:
assert json_body["trace_ids"] == ["trace456"]
assert json_body["run_id"] == run_id
def test_link_traces_to_run_with_empty_list_does_nothing():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
store.link_traces_to_run(trace_ids=[], run_id="run_abc")
mock_http.assert_not_called()
def test_unlink_traces_from_run_with_v4_trace_ids_uses_batch_v4_endpoint():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
location = "catalog.schema"
trace_ids = [
f"{TRACE_ID_V4_PREFIX}{location}/trace123",
f"{TRACE_ID_V4_PREFIX}{location}/trace456",
]
run_id = "run_abc"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.unlink_traces_from_run(trace_ids=trace_ids, run_id=run_id)
expected_json = {
"location_id": location,
"trace_ids": ["trace123", "trace456"],
"run_id": run_id,
}
mock_http.assert_called_once_with(
host_creds=creds,
endpoint=f"/api/4.0/mlflow/traces/{location}/unlink-from-run/batchDelete",
method="DELETE",
json=expected_json,
)
def test_unlink_traces_from_run_with_v3_trace_ids_raises_error():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
trace_ids = ["tr-123", "tr-456"]
run_id = "run_abc"
with pytest.raises(
MlflowException,
match="Unlinking traces from runs is only supported for traces with UC schema",
):
store.unlink_traces_from_run(trace_ids=trace_ids, run_id=run_id)
def test_unlink_traces_from_run_with_mixed_v3_v4_trace_ids_raises_error():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
location = "catalog.schema"
v3_trace_id = "tr-123"
v4_trace_id = f"{TRACE_ID_V4_PREFIX}{location}/trace456"
trace_ids = [v3_trace_id, v4_trace_id]
run_id = "run_abc"
# Should raise error because V3 traces are not supported for unlinking
with pytest.raises(
MlflowException,
match="Unlinking traces from runs is only supported for traces with UC schema",
):
store.unlink_traces_from_run(trace_ids=trace_ids, run_id=run_id)
def test_unlink_traces_from_run_with_different_locations_groups_by_location():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response = mock.MagicMock()
response.status_code = 200
response.text = json.dumps({})
location1 = "catalog1.schema1"
location2 = "catalog2.schema2"
trace_ids = [
f"{TRACE_ID_V4_PREFIX}{location1}/trace123",
f"{TRACE_ID_V4_PREFIX}{location2}/trace456",
f"{TRACE_ID_V4_PREFIX}{location1}/trace789",
]
run_id = "run_abc"
with mock.patch("mlflow.utils.rest_utils.http_request", return_value=response) as mock_http:
store.unlink_traces_from_run(trace_ids=trace_ids, run_id=run_id)
# Should make 2 separate batch calls, one for each location
assert mock_http.call_count == 2
# Verify calls were made for both locations
calls = mock_http.call_args_list
call_endpoints = {call.kwargs["endpoint"] for call in calls}
expected_endpoints = {
f"/api/4.0/mlflow/traces/{location1}/unlink-from-run/batchDelete",
f"/api/4.0/mlflow/traces/{location2}/unlink-from-run/batchDelete",
}
assert call_endpoints == expected_endpoints
# Verify the trace IDs were grouped correctly
for call in calls:
endpoint = call.kwargs["endpoint"]
json_body = call.kwargs["json"]
if location1 in endpoint:
assert set(json_body["trace_ids"]) == {"trace123", "trace789"}
elif location2 in endpoint:
assert json_body["trace_ids"] == ["trace456"]
assert json_body["run_id"] == run_id
def test_unlink_traces_from_run_with_empty_list_does_nothing():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with mock.patch("mlflow.utils.rest_utils.http_request") as mock_http:
store.unlink_traces_from_run(trace_ids=[], run_id="run_abc")
mock_http.assert_not_called()
def test_search_datasets_basic():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_data = {
"datasets": [
{
"dataset_id": "dataset_1",
"name": "test_dataset",
"digest": "abc123",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
"created_by": "user@example.com",
"last_updated_by": "user@example.com",
"source": '{"table_name":"main.default.test"}',
"source_type": "databricks-uc-table",
"last_sync_time": "1970-01-01T00:00:00Z",
}
],
"next_page_token": None,
}
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
return_value=mock.Mock(json=lambda: response_data),
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
result = store.search_datasets(experiment_ids=["exp_1"], max_results=100)
# Verify the mock was called correctly
mock_http.assert_called_once()
call_args = mock_http.call_args
endpoint = call_args[1]["endpoint"]
assert call_args[1]["method"] == "GET"
assert "/api/2.0/managed-evals/datasets" in endpoint
# URL encoding: = becomes %3D, ' becomes %27
assert "experiment_id%3D%27exp_1%27" in endpoint or "experiment_id='exp_1'" in endpoint
# Verify max_results is passed as page_size
assert "page_size=100" in endpoint
# Verify the results
assert len(result) == 1
assert result[0].dataset_id == "dataset_1"
assert result[0].name == "test_dataset"
assert result[0].digest == "abc123"
assert result[0].created_by == "user@example.com"
assert result[0].last_updated_by == "user@example.com"
assert result.token is None
def test_search_datasets_multiple_experiment_ids():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(
MlflowException,
match="Databricks managed-evals API does not support searching multiple experiment IDs",
):
store.search_datasets(experiment_ids=["exp_1", "exp_2"], max_results=100)
def test_search_datasets_pagination():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
mock_response = mock.MagicMock()
mock_response.json.return_value = {"datasets": [], "next_page_token": None}
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request", return_value=mock_response
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
store.search_datasets(experiment_ids=["exp_1"], max_results=50, page_token="prev_token")
# Verify the API call includes page_token
call_args = mock_http.call_args
endpoint = call_args[1]["endpoint"]
assert "page_token=prev_token" in endpoint
def test_search_datasets_empty_results():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
return_value=mock.Mock(json=lambda: {"datasets": []}),
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
result = store.search_datasets(experiment_ids=["exp_1"])
mock_http.assert_called_once()
assert len(result) == 0
assert result.token is None
@pytest.mark.parametrize(
("param_name", "param_value", "error_match"),
[
("filter_string", "name LIKE 'test%'", "filter_string parameter is not supported"),
("order_by", ["created_time DESC"], "order_by parameter is not supported"),
],
)
def test_search_datasets_unsupported_parameters(param_name, param_value, error_match):
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
kwargs = {"experiment_ids": ["exp_1"], param_name: param_value}
with pytest.raises(MlflowException, match=error_match):
store.search_datasets(**kwargs)
def test_search_datasets_endpoint_not_found():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
side_effect=RestException({"error_code": "ENDPOINT_NOT_FOUND", "message": "Not found"}),
):
with pytest.raises(MlflowException, match="not available in this Databricks workspace"):
store.search_datasets(experiment_ids=["exp_1"])
def test_search_datasets_missing_required_field():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_data = {
"datasets": [
{
"dataset_id": "dataset_1",
"digest": "abc123",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
# missing 'name' field
}
]
}
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
return_value=mock.Mock(json=lambda: response_data),
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
with pytest.raises(MlflowException, match="missing required field"):
store.search_datasets(experiment_ids=["exp_1"])
mock_http.assert_called_once()
def test_search_datasets_invalid_timestamp():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_data = {
"datasets": [
{
"dataset_id": "dataset_1",
"name": "test_dataset",
"digest": "abc123",
"create_time": "invalid-timestamp",
"last_update_time": "2025-11-28T21:30:53.195Z",
}
]
}
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
return_value=mock.Mock(json=lambda: response_data),
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
with pytest.raises(MlflowException, match="invalid timestamp format"):
store.search_datasets(experiment_ids=["exp_1"])
mock_http.assert_called_once()
@pytest.mark.parametrize(
("token_str", "expected_backend_token", "expected_offset"),
[
("simple_token", "simple_token", 0),
(
f"{base64.b64encode(b'backend_token_123').decode('utf-8')}:5",
"backend_token_123",
5,
),
(None, None, 0),
(":10", None, 10),
],
)
def test_composite_token_parsing(token_str, expected_backend_token, expected_offset):
token = CompositeToken.parse(token_str)
assert token.backend_token == expected_backend_token
assert token.offset == expected_offset
def test_search_datasets_multi_page_aggregation():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
responses = [
{
"datasets": [
{
"dataset_id": "dataset_1",
"name": "test_dataset_1",
"digest": "abc123",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
},
{
"dataset_id": "dataset_2",
"name": "test_dataset_2",
"digest": "def456",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
},
],
"next_page_token": "token1",
},
{"datasets": [], "next_page_token": "token2"},
{
"datasets": [
{
"dataset_id": f"dataset_{i}",
"name": f"test_dataset_{i}",
"digest": f"hash{i}",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
}
for i in range(3, 11)
],
"next_page_token": "token3",
},
]
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
side_effect=[mock.Mock(json=lambda r=r: r) for r in responses],
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
result = store.search_datasets(experiment_ids=["exp_1"], max_results=5)
assert mock_http.call_count == 3
assert {d.name for d in result} == {
"test_dataset_1",
"test_dataset_2",
"test_dataset_3",
"test_dataset_4",
"test_dataset_5",
}
def test_search_datasets_resume_from_composite_token():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_data = {
"datasets": [
{
"dataset_id": f"dataset_{i}",
"name": f"test_dataset_{i}",
"digest": f"hash{i}",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
}
for i in range(1, 16)
],
"next_page_token": "backend_token_B",
}
composite_token = CompositeToken(backend_token="backend_token_A", offset=5).encode()
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
return_value=mock.Mock(json=lambda: response_data),
),
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
result = store.search_datasets(
experiment_ids=["exp_1"], max_results=10, page_token=composite_token
)
assert {d.name for d in result} == {f"test_dataset_{i}" for i in range(6, 16)}
def test_search_datasets_exact_match_no_offset():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
response_data = {
"datasets": [
{
"dataset_id": f"dataset_{i}",
"name": f"test_dataset_{i}",
"digest": f"hash{i}",
"create_time": "2025-11-28T21:30:53.195Z",
"last_update_time": "2025-11-28T21:30:53.195Z",
}
for i in range(1, 11) # Exactly 10 datasets
],
"next_page_token": "backend_token_next",
}
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request",
return_value=mock.Mock(json=lambda: response_data),
) as mock_http,
mock.patch("mlflow.store.tracking.databricks_rest_store.verify_rest_response"),
):
result = store.search_datasets(experiment_ids=["exp_1"], max_results=10)
# Should return exactly 10 datasets
assert {d.name for d in result} == {f"test_dataset_{i}" for i in range(1, 11)}
# Token is the backend token, parseable as composite token with offset=0
parsed = CompositeToken.parse(result.token)
assert parsed.backend_token == "backend_token_next"
assert parsed.offset == 0 # No offset needed for exact match
mock_http.assert_called_once()
def test_create_issue_not_implemented():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(
MlflowNotImplementedException, match="Issue management is not supported in Databricks"
):
store.create_issue(
experiment_id="exp-123",
name="Test Issue",
description="Test description",
status="pending",
)
def test_get_issue_not_implemented():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(
MlflowNotImplementedException, match="Issue management is not supported in Databricks"
):
store.get_issue(issue_id="issue-123")
def test_update_issue_not_implemented():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(
MlflowNotImplementedException, match="Issue management is not supported in Databricks"
):
store.update_issue(issue_id="issue-123", status="resolved")
def test_search_issues_not_implemented():
creds = MlflowHostCreds("https://hello")
store = DatabricksTracingRestStore(lambda: creds)
with pytest.raises(
MlflowNotImplementedException, match="Issue management is not supported in Databricks"
):
store.search_issues(experiment_id="exp-123")
# ---------------------------------------------------------------------------
# Auto-start SQL warehouse before /api/4.0 and /api/5.0 MLflow tracing calls
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_ensure_running():
"""Patch ensure_sql_warehouse_running at its definition site so every caller sees the mock."""
with mock.patch("mlflow.utils.databricks_sql_warehouse.ensure_sql_warehouse_running") as m:
yield m
def _store():
return DatabricksTracingRestStore(lambda: MlflowHostCreds("https://test"))
def test_resolve_sql_warehouse_id_calls_ensure_running(sql_warehouse_id, mock_ensure_running):
wh_id = _store()._resolve_sql_warehouse_id()
assert wh_id == sql_warehouse_id
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_resolve_sql_warehouse_id_prefers_explicit_arg(sql_warehouse_id, mock_ensure_running):
wh_id = _store()._resolve_sql_warehouse_id("explicit-wh")
assert wh_id == "explicit-wh"
mock_ensure_running.assert_called_once_with("explicit-wh")
def test_resolve_sql_warehouse_id_without_env_is_noop(monkeypatch, mock_ensure_running):
monkeypatch.delenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, raising=False)
assert _store()._resolve_sql_warehouse_id() is None
mock_ensure_running.assert_not_called()
def test_append_sql_warehouse_id_param_triggers_ensure_running(
sql_warehouse_id, mock_ensure_running
):
store = _store()
endpoint = store._append_sql_warehouse_id_param("/api/4.0/mlflow/traces/x/tags/k")
assert endpoint.endswith(f"?sql_warehouse_id={sql_warehouse_id}")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_append_sql_warehouse_id_param_without_env_is_noop(monkeypatch, mock_ensure_running):
monkeypatch.delenv(MLFLOW_TRACING_SQL_WAREHOUSE_ID.name, raising=False)
store = _store()
endpoint = store._append_sql_warehouse_id_param("/api/4.0/mlflow/traces/x/tags/k")
assert endpoint == "/api/4.0/mlflow/traces/x/tags/k"
mock_ensure_running.assert_not_called()
def test_batch_get_traces_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
mock_response = BatchGetTraces.Response()
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
store.batch_get_traces(["trace:/catalog.schema/abc"], "catalog.schema")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_get_trace_info_v4_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
mock_response = GetTraceInfo.Response()
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
store.get_trace_info("trace:/catalog.schema/abc")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_search_traces_v4_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
from mlflow.protos.databricks_tracing_pb2 import SearchTracesOperation
mock_response = SearchTracesOperation(done=True)
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
store.search_traces(locations=["catalog.schema"])
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_create_or_get_trace_location_ensures_warehouse_running(
sql_warehouse_id, mock_ensure_running
):
store = _store()
location = UnityCatalog(catalog_name="catalog", schema_name="schema", table_prefix="prefix")
mock_response = CreateLocation.Response()
mock_response.uc_table_prefix.catalog_name = "catalog"
mock_response.uc_table_prefix.schema_name = "schema"
mock_response.uc_table_prefix.table_prefix = "prefix"
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
store.create_or_get_trace_location(location)
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_set_experiment_trace_location_ensures_warehouse_running(
sql_warehouse_id, mock_ensure_running
):
store = _store()
uc_schema = UCSchemaLocation(catalog_name="catalog", schema_name="schema")
create_location_response = mock.MagicMock()
create_location_response.uc_schema = ProtoUCSchemaLocation(
catalog_name="catalog",
schema_name="schema",
otel_spans_table_name="spans",
otel_logs_table_name="logs",
)
link_response = mock.MagicMock(status_code=200, text="{}")
with mock.patch.object(store, "_call_endpoint") as mock_call:
mock_call.side_effect = [create_location_response, link_response]
store.set_experiment_trace_location(location=uc_schema, experiment_id="123")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_delete_trace_tag_v4_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
with mock.patch.object(store, "_call_endpoint"):
store.delete_trace_tag(f"{TRACE_ID_V4_PREFIX}catalog.schema/abc", "k")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def _make_feedback(trace_id: str) -> Feedback:
return Feedback(
trace_id=trace_id,
name="quality",
value=0.9,
source=AssessmentSource(source_type=AssessmentSourceType.HUMAN, source_id="tester"),
)
def test_create_assessment_v4_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
feedback = _make_feedback(f"{TRACE_ID_V4_PREFIX}catalog.schema/abc")
with mock.patch.object(store, "_call_endpoint", return_value=assessment_to_proto(feedback)):
store.create_assessment(feedback)
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_get_assessment_v4_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
feedback = _make_feedback(f"{TRACE_ID_V4_PREFIX}catalog.schema/abc")
with mock.patch.object(store, "_call_endpoint", return_value=assessment_to_proto(feedback)):
store.get_assessment(f"{TRACE_ID_V4_PREFIX}catalog.schema/abc", "assessment-1")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_delete_assessment_v4_ensures_warehouse_running(sql_warehouse_id, mock_ensure_running):
store = _store()
with mock.patch.object(store, "_call_endpoint"):
store.delete_assessment(f"{TRACE_ID_V4_PREFIX}catalog.schema/abc", "assessment-1")
mock_ensure_running.assert_called_once_with(sql_warehouse_id)
def test_search_unified_traces_does_not_auto_start_warehouse(sql_warehouse_id, mock_ensure_running):
"""
Scope guard: `_search_unified_traces` targets /api/2.0 (MlflowService.SearchUnifiedTraces) and
is out of scope for auto-start.
"""
store = _store()
from mlflow.protos.service_pb2 import SearchUnifiedTraces
mock_response = SearchUnifiedTraces.Response()
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
store.search_traces(locations=["1234"], model_id="model-1")
mock_ensure_running.assert_not_called()
def test_get_online_trace_details_does_not_auto_start_warehouse(
sql_warehouse_id, mock_ensure_running
):
"""
Scope guard: `get_online_trace_details` targets /api/2.0 (MlflowService.GetOnlineTraceDetails).
"""
store = _store()
from mlflow.protos.service_pb2 import GetOnlineTraceDetails
mock_response = GetOnlineTraceDetails.Response()
with mock.patch.object(store, "_call_endpoint", return_value=mock_response):
store.get_online_trace_details(
trace_id="abc",
source_inference_table="t",
source_databricks_request_id="r",
)
mock_ensure_running.assert_not_called()
def test_log_spans_does_not_auto_start_warehouse(sql_warehouse_id, mock_ensure_running):
"""
Scope guard: `log_spans` posts to /api/2.0/otel and does not carry a SQL warehouse ID.
"""
store = _store()
response = mock.MagicMock(status_code=200, text="{}")
spans = create_mock_spans()
with (
mock.patch(
"mlflow.store.tracking.databricks_rest_store.get_databricks_workspace_client_config",
return_value=mock.MagicMock(authenticate=lambda: {}),
),
mock.patch(
"mlflow.store.tracking.databricks_rest_store.http_request", return_value=response
),
mock.patch(
"mlflow.store.tracking.databricks_rest_store.verify_rest_response",
return_value=response,
),
):
store.log_spans("catalog.schema", spans, tracking_uri="databricks")
mock_ensure_running.assert_not_called()
# Scope guard: `unset_experiment_trace_location` does not carry a SQL warehouse ID.
def test_unset_experiment_trace_location_does_not_auto_start_warehouse(
sql_warehouse_id, mock_ensure_running
):
store = _store()
uc_schema = UCSchemaLocation(catalog_name="catalog", schema_name="schema")
with mock.patch.object(store, "_call_endpoint"):
store.unset_experiment_trace_location(experiment_id="123", location=uc_schema)
mock_ensure_running.assert_not_called()