370 lines
12 KiB
Python
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)
|