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

370 lines
12 KiB
Python

import json
import time
import uuid
from pathlib import Path
from unittest import mock
import pytest
import sqlalchemy
from opentelemetry import trace as trace_api
from opentelemetry.sdk.resources import Resource as _OTelResource
from opentelemetry.sdk.trace import ReadableSpan as OTelReadableSpan
from mlflow.entities import ViewType, trace_location
from mlflow.entities.span import Span, create_mlflow_span
from mlflow.entities.trace_info import TraceInfo
from mlflow.entities.trace_state import TraceState
from mlflow.entities.workspace import Workspace
from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES, MLFLOW_TRACKING_URI
from mlflow.store.db.db_types import MSSQL, MYSQL, POSTGRES, SQLITE
from mlflow.store.tracking import SEARCH_MAX_RESULTS_DEFAULT
from mlflow.store.tracking.dbmodels.models import (
SqlDataset,
SqlEntityAssociation,
SqlEvaluationDataset,
SqlEvaluationDatasetRecord,
SqlExperiment,
SqlExperimentTag,
SqlGatewaySecret,
SqlInput,
SqlInputTag,
SqlLatestMetric,
SqlLoggedModel,
SqlLoggedModelMetric,
SqlLoggedModelParam,
SqlLoggedModelTag,
SqlMetric,
SqlOnlineScoringConfig,
SqlParam,
SqlRun,
SqlScorer,
SqlScorerVersion,
SqlTag,
SqlTraceInfo,
SqlTraceMetadata,
SqlTraceTag,
)
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
from mlflow.store.tracking.sqlalchemy_workspace_store import WorkspaceAwareSqlAlchemyStore
from mlflow.tracing.utils import TraceJSONEncoder
from mlflow.utils import mlflow_tags
from mlflow.utils.time import get_current_time_millis
from mlflow.utils.workspace_context import WorkspaceContext
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
DB_URI = "sqlite:///"
ARTIFACT_URI = "artifact_folder"
pytestmark = pytest.mark.notrackingurimock
IS_MSSQL = MLFLOW_TRACKING_URI.get() and MLFLOW_TRACKING_URI.get().startswith("mssql+pyodbc")
@pytest.fixture(params=[False, True], ids=["workspace-disabled", "workspace-enabled"])
def workspaces_enabled(request, monkeypatch, disable_workspace_mode_by_default):
"""
Run every test in this module with workspaces disabled and enabled to cover both code paths.
"""
enabled = request.param
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true" if enabled else "false")
if enabled:
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
yield enabled
else:
yield enabled
@pytest.fixture
def store(tmp_path: Path, db_uri: str, workspaces_enabled: bool) -> SqlAlchemyStore:
store_cls = WorkspaceAwareSqlAlchemyStore if workspaces_enabled else SqlAlchemyStore
artifact_uri = tmp_path / "artifacts"
artifact_uri.mkdir(exist_ok=True)
if db_uri_env := MLFLOW_TRACKING_URI.get():
s = store_cls(db_uri_env, artifact_uri.as_uri())
yield s
_cleanup_database(s)
else:
s = store_cls(db_uri, artifact_uri.as_uri())
yield s
@pytest.fixture
def store_and_trace_info(store):
exp_id = store.create_experiment("test")
timestamp_ms = get_current_time_millis()
return store, store.start_trace(
TraceInfo(
trace_id=f"tr-{uuid.uuid4()}",
trace_location=trace_location.TraceLocation.from_experiment_id(exp_id),
request_time=timestamp_ms,
execution_duration=0,
state=TraceState.OK,
tags={},
trace_metadata={},
client_request_id=f"tr-{uuid.uuid4()}",
request_preview=None,
response_preview=None,
),
)
def _get_store(tmp_path: Path):
db_uri = MLFLOW_TRACKING_URI.get() or f"{DB_URI}{tmp_path / 'temp.db'}"
artifact_uri = tmp_path / "artifacts"
artifact_uri.mkdir(exist_ok=True)
return SqlAlchemyStore(db_uri, artifact_uri.as_uri())
def _get_query_to_reset_experiment_id(store: SqlAlchemyStore):
dialect = store._get_dialect()
if dialect == POSTGRES:
return "ALTER SEQUENCE experiments_experiment_id_seq RESTART WITH 1"
elif dialect == MYSQL:
return "ALTER TABLE experiments AUTO_INCREMENT = 1"
elif dialect == MSSQL:
return "DBCC CHECKIDENT (experiments, RESEED, 0)"
elif dialect == SQLITE:
# In SQLite, deleting all experiments resets experiment_id
return None
raise ValueError(f"Invalid dialect: {dialect}")
def _cleanup_database(store: SqlAlchemyStore):
with store.ManagedSessionMaker() as session:
# Delete all rows in all tables
for model in (
SqlLoggedModel,
SqlLoggedModelMetric,
SqlLoggedModelParam,
SqlLoggedModelTag,
SqlParam,
SqlMetric,
SqlLatestMetric,
SqlTag,
SqlInputTag,
SqlInput,
SqlDataset,
SqlRun,
SqlTraceTag,
SqlTraceMetadata,
SqlTraceInfo,
SqlEvaluationDatasetRecord,
SqlEntityAssociation,
SqlEvaluationDataset,
SqlExperimentTag,
SqlOnlineScoringConfig,
SqlScorerVersion,
SqlScorer,
SqlGatewaySecret,
SqlExperiment,
):
session.query(model).delete()
# Reset experiment_id to start at 1
if reset_experiment_id := _get_query_to_reset_experiment_id(store):
session.execute(sqlalchemy.sql.text(reset_experiment_id))
# Recreate the default experiment (id=0) so that tests using the global registry
# cache (e.g., mlflow.start_run()) can still find it after cleanup.
store._create_default_experiment(session)
if isinstance(store, WorkspaceAwareSqlAlchemyStore):
provider = store._get_workspace_provider_instance()
# Reset workspace-level overrides when tests share a cached workspace store
# against a long-lived backend store URI.
for workspace in provider.list_workspaces():
if workspace.name != DEFAULT_WORKSPACE_NAME:
provider.delete_workspace(workspace.name)
provider.update_workspace(
Workspace(
name=DEFAULT_WORKSPACE_NAME,
default_artifact_root="",
trace_archival_location="",
trace_archival_retention="",
)
)
with provider._artifact_root_cache_lock:
provider._artifact_root_cache.clear()
with provider._trace_archival_config_cache_lock:
provider._trace_archival_config_cache.clear()
def _create_experiments(store: SqlAlchemyStore, names) -> str | list[str]:
if isinstance(names, (list, tuple)):
ids = []
for name in names:
# Sleep to ensure each experiment has a unique creation_time for
# deterministic experiment search results
time.sleep(0.001)
ids.append(store.create_experiment(name=name))
return ids
time.sleep(0.001)
return store.create_experiment(name=names)
def _get_run_configs(experiment_id=None, tags=None, start_time=None):
return {
"experiment_id": experiment_id,
"user_id": "Anderson",
"start_time": get_current_time_millis() if start_time is None else start_time,
"tags": tags,
"run_name": "name",
}
def _run_factory(store: SqlAlchemyStore, config=None):
if not config:
config = _get_run_configs()
if not config.get("experiment_id", None):
config["experiment_id"] = _create_experiments(store, "test exp")
return store.create_run(**config)
def _clear_in_memory_engine():
engine = SqlAlchemyStore._engine_map.pop("sqlite:///:memory:", None)
if engine is not None:
engine.dispose()
def _search_runs(
store: SqlAlchemyStore,
experiment_id,
filter_string=None,
run_view_type=ViewType.ALL,
max_results=SEARCH_MAX_RESULTS_DEFAULT,
):
exps = [experiment_id] if isinstance(experiment_id, str) else experiment_id
return [
r.info.run_id for r in store.search_runs(exps, filter_string, run_view_type, max_results)
]
def _get_ordered_runs(store: SqlAlchemyStore, order_clauses, experiment_id):
return [
r.data.tags[mlflow_tags.MLFLOW_RUN_NAME]
for r in store.search_runs(
experiment_ids=[experiment_id],
filter_string="",
run_view_type=ViewType.ALL,
order_by=order_clauses,
)
]
def _verify_logged(store, run_id, metrics, params, tags):
run = store.get_run(run_id)
all_metrics = sum((store.get_metric_history(run_id, key) for key in run.data.metrics), [])
assert len(all_metrics) == len(metrics)
logged_metrics = [(m.key, m.value, m.timestamp, m.step) for m in all_metrics]
assert set(logged_metrics) == {(m.key, m.value, m.timestamp, m.step) for m in metrics}
logged_tags = set(run.data.tags.items())
assert {(tag.key, tag.value) for tag in tags} <= logged_tags
assert len(run.data.params) == len(params)
assert set(run.data.params.items()) == {(param.key, param.value) for param in params}
def create_mock_span_context(trace_id_num=12345, span_id_num=111) -> trace_api.SpanContext:
context = mock.Mock()
context.trace_id = trace_id_num
context.span_id = span_id_num
context.is_remote = False
context.trace_flags = trace_api.TraceFlags(1)
context.trace_state = trace_api.TraceState()
return context
def create_test_span(
trace_id,
name="test_span",
span_id=111,
parent_id=None,
status=trace_api.StatusCode.UNSET,
status_desc=None,
start_ns=1000000000,
end_ns=2000000000,
span_type="LLM",
trace_num=12345,
attributes=None,
links=None,
) -> Span:
context = create_mock_span_context(trace_num, span_id)
parent_context = create_mock_span_context(trace_num, parent_id) if parent_id else None
attributes = attributes or {}
otel_span = OTelReadableSpan(
name=name,
context=context,
parent=parent_context,
attributes={
"mlflow.traceRequestId": json.dumps(trace_id),
"mlflow.spanType": json.dumps(span_type, cls=TraceJSONEncoder),
**{k: json.dumps(v, cls=TraceJSONEncoder) for k, v in attributes.items()},
},
start_time=start_ns,
end_time=end_ns,
status=trace_api.Status(status, status_desc),
resource=_OTelResource.get_empty(),
)
span = create_mlflow_span(otel_span, trace_id, span_type)
if links:
span._links = list(links)
return span
def create_test_otel_span(
trace_id,
name="test_span",
parent=None,
status_code=trace_api.StatusCode.UNSET,
status_description=None,
start_time=1000000000,
end_time=2000000000,
span_type="LLM",
trace_id_num=12345,
span_id_num=111,
) -> OTelReadableSpan:
context = create_mock_span_context(trace_id_num, span_id_num)
return OTelReadableSpan(
name=name,
context=context,
parent=parent,
attributes={
"mlflow.traceRequestId": json.dumps(trace_id),
"mlflow.spanType": json.dumps(span_type, cls=TraceJSONEncoder),
},
start_time=start_time,
end_time=end_time,
status=trace_api.Status(status_code, status_description),
resource=_OTelResource.get_empty(),
)
def _create_trace(
store: SqlAlchemyStore,
trace_id: str,
experiment_id=0,
request_time=0,
execution_duration=0,
state=TraceState.OK,
trace_metadata=None,
tags=None,
client_request_id=None,
) -> TraceInfo:
trace_info = TraceInfo(
trace_id=trace_id,
trace_location=trace_location.TraceLocation.from_experiment_id(experiment_id),
request_time=request_time,
execution_duration=execution_duration,
state=state,
tags=tags or {},
trace_metadata=trace_metadata or {},
client_request_id=client_request_id,
)
return store.start_trace(trace_info)