427 lines
14 KiB
Python
427 lines
14 KiB
Python
import uuid
|
|
|
|
import pytest
|
|
|
|
from mlflow.entities import ExperimentTag
|
|
from mlflow.entities.model_registry import ModelVersionTag, RegisteredModelTag
|
|
from mlflow.entities.workspace import Workspace
|
|
from mlflow.environment_variables import MLFLOW_ENABLE_WORKSPACES
|
|
from mlflow.store.db.workspace_move import _SPEC_BY_MODEL, MoveResult, move_resources
|
|
from mlflow.store.model_registry.sqlalchemy_workspace_store import (
|
|
WorkspaceAwareSqlAlchemyStore as WorkspaceAwareRegistryStore,
|
|
)
|
|
from mlflow.store.workspace.sqlalchemy_store import (
|
|
SqlAlchemyStore as WorkspaceStore,
|
|
)
|
|
from mlflow.tracking._tracking_service import utils as tracking_utils
|
|
from mlflow.utils.workspace_context import WorkspaceContext
|
|
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _enable_workspaces(monkeypatch):
|
|
monkeypatch.setenv(MLFLOW_ENABLE_WORKSPACES.name, "true")
|
|
|
|
|
|
@pytest.fixture
|
|
def tracking_store(tmp_path, db_uri, _enable_workspaces):
|
|
artifact_dir = tmp_path / "artifacts"
|
|
artifact_dir.mkdir()
|
|
store = tracking_utils._get_sqlalchemy_store(db_uri, artifact_dir.as_uri())
|
|
try:
|
|
yield store
|
|
finally:
|
|
store._dispose_engine()
|
|
|
|
|
|
@pytest.fixture
|
|
def registry_store(db_uri, _enable_workspaces):
|
|
store = WorkspaceAwareRegistryStore(db_uri)
|
|
try:
|
|
yield store
|
|
finally:
|
|
store.engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def workspace_store(db_uri, _enable_workspaces):
|
|
store = WorkspaceStore(db_uri)
|
|
try:
|
|
yield store
|
|
finally:
|
|
store._engine.dispose()
|
|
|
|
|
|
@pytest.fixture
|
|
def engine(tracking_store):
|
|
return tracking_store.engine
|
|
|
|
|
|
def _create_workspace(ws_store, name):
|
|
ws_store.create_workspace(Workspace(name=name))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Experiments
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_move_experiments_by_name(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
tracking_store.create_experiment("exp-1")
|
|
tracking_store.create_experiment("exp-2")
|
|
tracking_store.create_experiment("exp-3")
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
names=["exp-1", "exp-3"],
|
|
)
|
|
|
|
assert result.names == ["exp-1", "exp-3"]
|
|
assert result.row_count == 2
|
|
|
|
with WorkspaceContext("team-a"):
|
|
assert tracking_store.get_experiment_by_name("exp-1") is not None
|
|
assert tracking_store.get_experiment_by_name("exp-3") is not None
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
assert tracking_store.get_experiment_by_name("exp-2") is not None
|
|
assert tracking_store.get_experiment_by_name("exp-1") is None
|
|
|
|
|
|
def test_move_experiment_by_tag(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
exp_id_1 = tracking_store.create_experiment("exp-1")
|
|
tracking_store.create_experiment("exp-2")
|
|
tracking_store.set_experiment_tag(exp_id_1, ExperimentTag("team", "team-a"))
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
tags=[("team", "team-a")],
|
|
)
|
|
|
|
assert result.names == ["exp-1"]
|
|
assert result.row_count == 1
|
|
|
|
|
|
def test_error_name_and_tag_mutually_exclusive(workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with pytest.raises(RuntimeError, match="mutually exclusive"):
|
|
move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
names=["exp-1"],
|
|
tags=[("team", "team-a")],
|
|
)
|
|
|
|
|
|
def test_move_experiment_by_multiple_tags_and(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
exp_id_1 = tracking_store.create_experiment("exp-1")
|
|
exp_id_2 = tracking_store.create_experiment("exp-2")
|
|
tracking_store.set_experiment_tag(exp_id_1, ExperimentTag("team", "team-a"))
|
|
tracking_store.set_experiment_tag(exp_id_1, ExperimentTag("env", "prod"))
|
|
tracking_store.set_experiment_tag(exp_id_2, ExperimentTag("team", "team-a"))
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
tags=[("team", "team-a"), ("env", "prod")],
|
|
)
|
|
|
|
assert result.names == ["exp-1"]
|
|
assert result.row_count == 1
|
|
|
|
|
|
def test_move_all_experiments_no_filter(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
tracking_store.create_experiment("exp-1")
|
|
tracking_store.create_experiment("exp-2")
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
)
|
|
|
|
assert len(result.names) >= 2
|
|
assert "exp-1" in result.names
|
|
assert "exp-2" in result.names
|
|
assert result.row_count >= 2
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registered models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_move_model_by_tag(registry_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
registry_store.create_registered_model("model-1")
|
|
registry_store.create_registered_model("model-2")
|
|
registry_store.set_registered_model_tag("model-1", RegisteredModelTag("team", "team-a"))
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="registered_models",
|
|
tags=[("team", "team-a")],
|
|
)
|
|
|
|
assert result.names == ["model-1"]
|
|
assert result.row_count == 1
|
|
|
|
with WorkspaceContext("team-a"):
|
|
assert registry_store.get_registered_model("model-1") is not None
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
assert registry_store.get_registered_model("model-2") is not None
|
|
|
|
|
|
def test_move_model_cascades_to_child_tables(registry_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
registry_store.create_registered_model("model-1")
|
|
mv1 = registry_store.create_model_version(
|
|
"model-1", "s3://bucket/v1", run_id=uuid.uuid4().hex
|
|
)
|
|
registry_store.create_model_version("model-1", "s3://bucket/v2", run_id=uuid.uuid4().hex)
|
|
registry_store.set_registered_model_tag("model-1", RegisteredModelTag("stage", "prod"))
|
|
registry_store.set_registered_model_alias("model-1", "champion", str(mv1.version))
|
|
registry_store.set_model_version_tag(
|
|
"model-1", str(mv1.version), ModelVersionTag("metric", "0.95")
|
|
)
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="registered_models",
|
|
names=["model-1"],
|
|
)
|
|
|
|
assert result.names == ["model-1"]
|
|
assert result.row_count == 1
|
|
|
|
with WorkspaceContext("team-a"):
|
|
rm = registry_store.get_registered_model("model-1")
|
|
assert rm.tags == {"stage": "prod"}
|
|
assert rm.aliases == {"champion": mv1.version}
|
|
|
|
versions = registry_store.search_model_versions(filter_string="name='model-1'")
|
|
assert len(versions) == 2
|
|
|
|
mv = registry_store.get_model_version("model-1", str(mv1.version))
|
|
assert mv.tags == {"metric": "0.95"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Conflict detection, dry run, and error handling
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_conflict_detection(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
tracking_store.create_experiment("dup-exp")
|
|
with WorkspaceContext("team-a"):
|
|
tracking_store.create_experiment("dup-exp")
|
|
|
|
with pytest.raises(RuntimeError, match="already exist in workspace"):
|
|
move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
names=["dup-exp"],
|
|
)
|
|
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
assert tracking_store.get_experiment_by_name("dup-exp") is not None
|
|
|
|
|
|
def test_dry_run_does_not_modify(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
tracking_store.create_experiment("exp-1")
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
names=["exp-1"],
|
|
dry_run=True,
|
|
)
|
|
|
|
assert result.names == ["exp-1"]
|
|
assert result.row_count == 1
|
|
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
assert tracking_store.get_experiment_by_name("exp-1") is not None
|
|
|
|
|
|
def test_validation_errors(engine):
|
|
with pytest.raises(RuntimeError, match="does not exist"):
|
|
move_resources(
|
|
engine,
|
|
source_workspace="nonexistent",
|
|
target_workspace=DEFAULT_WORKSPACE_NAME,
|
|
resource_type="experiments",
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="does not exist"):
|
|
move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="nonexistent",
|
|
resource_type="experiments",
|
|
)
|
|
|
|
with pytest.raises(RuntimeError, match="must be different"):
|
|
move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace=DEFAULT_WORKSPACE_NAME,
|
|
resource_type="experiments",
|
|
)
|
|
|
|
|
|
def test_error_tag_on_unsupported_resource_type(workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with pytest.raises(RuntimeError, match="does not support tag filtering"):
|
|
move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="webhooks",
|
|
tags=[("team", "team-a")],
|
|
)
|
|
|
|
|
|
def test_noop_when_nothing_matches(tracking_store, workspace_store, engine):
|
|
_create_workspace(workspace_store, "team-a")
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
tracking_store.create_experiment("exp-1")
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
names=["nonexistent"],
|
|
)
|
|
assert result == MoveResult(names=[], row_count=0)
|
|
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="team-a",
|
|
resource_type="experiments",
|
|
tags=[("team", "nonexistent")],
|
|
)
|
|
assert result == MoveResult(names=[], row_count=0)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# All resource types
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_move_all_resource_types(
|
|
tracking_store, registry_store, workspace_store, engine, monkeypatch
|
|
):
|
|
from mlflow.entities.webhook import WebhookAction, WebhookEntity, WebhookEvent
|
|
from mlflow.store.jobs.sqlalchemy_workspace_store import (
|
|
WorkspaceAwareSqlAlchemyJobStore,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"mlflow.store.model_registry.sqlalchemy_store._validate_webhook_url", lambda url: None
|
|
)
|
|
_create_workspace(workspace_store, "target")
|
|
job_store = WorkspaceAwareSqlAlchemyJobStore(str(engine.url))
|
|
|
|
with WorkspaceContext(DEFAULT_WORKSPACE_NAME):
|
|
tracking_store.create_experiment("move-exp")
|
|
tracking_store.create_dataset("move-ds")
|
|
registry_store.create_registered_model("move-model")
|
|
registry_store.create_webhook(
|
|
name="move-webhook",
|
|
url="https://example.com/hook",
|
|
events=[WebhookEvent(WebhookEntity.MODEL_VERSION, WebhookAction.CREATED)],
|
|
)
|
|
job_store.create_job(job_name="move-job", params="{}")
|
|
|
|
resource_names = {
|
|
"experiments": "move-exp",
|
|
"evaluation_datasets": "move-ds",
|
|
"registered_models": "move-model",
|
|
"webhooks": "move-webhook",
|
|
"jobs": "move-job",
|
|
}
|
|
|
|
for resource_type, name in resource_names.items():
|
|
result = move_resources(
|
|
engine,
|
|
source_workspace=DEFAULT_WORKSPACE_NAME,
|
|
target_workspace="target",
|
|
resource_type=resource_type,
|
|
names=[name],
|
|
)
|
|
assert name in result.names, f"Expected {name!r} in moved list for {resource_type}"
|
|
assert result.row_count >= 1, f"Expected row_count >= 1 for {resource_type}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Spec coverage
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_all_workspace_root_models_have_spec():
|
|
from mlflow.store.tracking.dbmodels.models import (
|
|
SqlGatewayBudgetPolicy,
|
|
SqlGatewayEndpoint,
|
|
SqlGatewayGuardrail,
|
|
SqlGatewayModelDefinition,
|
|
SqlGatewaySecret,
|
|
)
|
|
from mlflow.store.workspace.sqlalchemy_store import _WORKSPACE_ROOT_MODELS
|
|
|
|
# Gateway resources are intentionally excluded due to inter-table FK
|
|
# dependencies that make moving them independently unsafe.
|
|
_INTENTIONALLY_OMITTED = {
|
|
SqlGatewaySecret,
|
|
SqlGatewayEndpoint,
|
|
SqlGatewayModelDefinition,
|
|
SqlGatewayBudgetPolicy,
|
|
SqlGatewayGuardrail,
|
|
}
|
|
|
|
missing = {
|
|
model.__name__
|
|
for model in _WORKSPACE_ROOT_MODELS
|
|
if model not in _SPEC_BY_MODEL and model not in _INTENTIONALLY_OMITTED
|
|
}
|
|
assert not missing, (
|
|
f"These models are in _WORKSPACE_ROOT_MODELS but have no entry in "
|
|
f"_SPEC_BY_MODEL (mlflow/store/db/workspace_move.py): {sorted(missing)}. "
|
|
f"Add a _ResourceSpec so move-resources can handle them, or add "
|
|
f"to _INTENTIONALLY_OMITTED if they cannot be moved independently."
|
|
)
|