Files
mlflow--mlflow/tests/db/test_workspace_migration.py
2026-07-13 13:22:34 +08:00

1228 lines
36 KiB
Python

import os
import re
from contextlib import contextmanager
import pytest
import sqlalchemy as sa
from alembic import command
from mlflow.store.db.utils import _get_alembic_config
from mlflow.store.tracking.dbmodels.initial_models import Base as InitialBase
_LEGACY_REGISTERED_MODEL_TAGS = sa.table(
"registered_model_tags",
sa.column("key"),
sa.column("value"),
sa.column("name"),
)
_LEGACY_MODEL_VERSION_TAGS = sa.table(
"model_version_tags",
sa.column("key"),
sa.column("value"),
sa.column("name"),
sa.column("version"),
)
_LEGACY_REGISTERED_MODEL_ALIASES = sa.table(
"registered_model_aliases",
sa.column("alias"),
sa.column("version"),
sa.column("name"),
)
_LEGACY_EVALUATION_DATASETS = sa.table(
"evaluation_datasets",
sa.column("dataset_id"),
sa.column("name"),
sa.column("schema"),
sa.column("profile"),
sa.column("digest"),
sa.column("created_time"),
sa.column("last_update_time"),
sa.column("created_by"),
sa.column("last_updated_by"),
)
_LEGACY_SECRETS = sa.table(
"secrets",
sa.column("secret_id"),
sa.column("secret_name"),
sa.column("encrypted_value"),
sa.column("wrapped_dek"),
sa.column("kek_version"),
sa.column("masked_value"),
sa.column("provider"),
sa.column("auth_config"),
sa.column("description"),
sa.column("created_by"),
sa.column("created_at"),
sa.column("last_updated_by"),
sa.column("last_updated_at"),
)
_LEGACY_ENDPOINTS = sa.table(
"endpoints",
sa.column("endpoint_id"),
sa.column("name"),
sa.column("created_by"),
sa.column("created_at"),
sa.column("last_updated_by"),
sa.column("last_updated_at"),
)
_LEGACY_MODEL_DEFINITIONS = sa.table(
"model_definitions",
sa.column("model_definition_id"),
sa.column("name"),
sa.column("secret_id"),
sa.column("provider"),
sa.column("model_name"),
sa.column("created_by"),
sa.column("created_at"),
sa.column("last_updated_by"),
sa.column("last_updated_at"),
)
_WORKSPACE_TABLES = (
"experiments",
"registered_models",
"model_versions",
"registered_model_tags",
"model_version_tags",
"registered_model_aliases",
"evaluation_datasets",
"secrets",
"endpoints",
"model_definitions",
"webhooks",
"jobs",
)
_REGISTERED_MODEL_TAGS = sa.table(
"registered_model_tags",
sa.column("workspace"),
sa.column("key"),
sa.column("value"),
sa.column("name"),
)
_MODEL_VERSION_TAGS = sa.table(
"model_version_tags",
sa.column("workspace"),
sa.column("key"),
sa.column("value"),
sa.column("name"),
sa.column("version"),
)
_REGISTERED_MODEL_ALIASES = sa.table(
"registered_model_aliases",
sa.column("workspace"),
sa.column("name"),
sa.column("alias"),
sa.column("version"),
)
_EVALUATION_DATASETS = sa.table(
"evaluation_datasets",
sa.column("dataset_id"),
sa.column("name"),
sa.column("schema"),
sa.column("profile"),
sa.column("digest"),
sa.column("created_time"),
sa.column("last_update_time"),
sa.column("created_by"),
sa.column("last_updated_by"),
sa.column("workspace"),
)
REVISION = "1b5f0d9ad7c1"
PREVIOUS_REVISION = "c8d9e0f1a2b3"
DB_URI = os.environ.get("MLFLOW_TRACKING_URI")
USE_EXTERNAL_DB = DB_URI is not None and not DB_URI.startswith("sqlite")
@pytest.fixture(scope="session", autouse=True)
def _upgrade_external_db_to_head_after_suite():
"""
When running under Docker (i.e., with a shared external DB), make sure the DB ends up on the
latest revision once this module finishes. Individual tests intentionally downgrade to the
pre-workspace schema, so without this hook, the subsequent suites in the database workflow would
run against an outdated schema after new migrations land.
"""
yield
if USE_EXTERNAL_DB:
config = _get_alembic_config(DB_URI)
command.upgrade(config, "head")
@contextmanager
def _identity_insert(conn, table_name: str):
if conn.dialect.name != "mssql":
yield
return
conn.execute(sa.text(f"SET IDENTITY_INSERT {table_name} ON"))
try:
yield
finally:
conn.execute(sa.text(f"SET IDENTITY_INSERT {table_name} OFF"))
def _insert_table_row(conn, table, **values):
conn.execute(sa.insert(table).values(**values))
def _assert_workspace_column(inspector, table_name: str, expected_default: str):
columns = inspector.get_columns(table_name)
workspace = next((col for col in columns if col["name"] == "workspace"), None)
assert workspace is not None, f"{table_name} lacks workspace column"
assert not workspace.get("nullable", False)
default_value = _get_workspace_default(workspace)
assert default_value == expected_default
def _assert_workspace_columns(inspector, expected_default: str = "default"):
for table in _WORKSPACE_TABLES:
_assert_workspace_column(inspector, table, expected_default)
def _has_index(inspector, table: str, index_name: str, columns: list[str]):
indexes = inspector.get_indexes(table)
return any(
index["name"] == index_name and index.get("column_names") == columns for index in indexes
)
def _prepare_database(tmp_path):
if USE_EXTERNAL_DB:
engine = sa.create_engine(DB_URI)
with engine.begin() as conn:
metadata = sa.MetaData()
metadata.reflect(bind=conn)
metadata.drop_all(bind=conn)
InitialBase.metadata.create_all(conn)
config = _get_alembic_config(DB_URI)
else:
db_path = tmp_path / "workspace_migration.sqlite"
url = f"sqlite:///{db_path}"
engine = sa.create_engine(url)
InitialBase.metadata.create_all(engine)
config = _get_alembic_config(url)
command.upgrade(config, PREVIOUS_REVISION)
return engine, config
def _seed_pre_workspace_entities(conn):
# This intentionally uses raw SQL matching the legacy schema (no workspace columns)
# so the migration under test is fully responsible for adding/backfilling the new
# fields. The helper insert functions below operate on the post-migration schema
# and therefore cannot be reused here.
with _identity_insert(conn, "experiments"):
conn.execute(
sa.text(
"""
INSERT INTO experiments (
experiment_id,
name,
artifact_location,
lifecycle_stage,
creation_time,
last_update_time
)
VALUES (
:experiment_id,
:name,
:artifact_location,
:lifecycle_stage,
:creation_time,
:last_update_time
)
"""
),
{
"experiment_id": 1,
"name": "exp-default",
"artifact_location": "path",
"lifecycle_stage": "active",
"creation_time": 0,
"last_update_time": 0,
},
)
conn.execute(
sa.text(
"""
INSERT INTO runs (
run_uuid,
name,
source_type,
source_name,
entry_point_name,
user_id,
status,
start_time,
end_time,
source_version,
lifecycle_stage,
artifact_uri,
experiment_id
)
VALUES (
:run_uuid,
:name,
:source_type,
:source_name,
:entry_point_name,
:user_id,
:status,
:start_time,
:end_time,
:source_version,
:lifecycle_stage,
:artifact_uri,
:experiment_id
)
"""
),
{
"run_uuid": "run-default",
"name": "upgrade-validation-run",
"source_type": "LOCAL",
"source_name": "script.py",
"entry_point_name": "main",
"user_id": "user",
"status": "FINISHED",
"start_time": 0,
"end_time": 1,
"source_version": "abc123",
"lifecycle_stage": "active",
"artifact_uri": "path/artifacts",
"experiment_id": 1,
},
)
conn.execute(
sa.text(
"""
INSERT INTO registered_models (name, creation_time, last_updated_time, description)
VALUES (:name, :creation_time, :last_updated_time, :description)
"""
),
{"name": "rm-default", "creation_time": 0, "last_updated_time": 0, "description": "desc"},
)
conn.execute(
sa.text(
"""
INSERT INTO model_versions (
name,
version,
creation_time,
last_updated_time,
user_id,
current_stage,
description,
source,
run_id,
status,
status_message,
run_link,
storage_location
)
VALUES (
:name,
:version,
:creation_time,
:last_updated_time,
:user_id,
:current_stage,
:description,
:source,
:run_id,
:status,
:status_message,
:run_link,
:storage_location
)
"""
),
{
"name": "rm-default",
"version": 1,
"creation_time": 0,
"last_updated_time": 0,
"user_id": "user",
"current_stage": "None",
"description": "desc",
"source": "source",
"run_id": "run-id",
"status": "READY",
"status_message": "message",
"run_link": "link",
"storage_location": "location",
},
)
_insert_table_row(
conn,
_LEGACY_REGISTERED_MODEL_TAGS,
key="tag",
value="value",
name="rm-default",
)
_insert_table_row(
conn,
_LEGACY_MODEL_VERSION_TAGS,
key="tag",
value="value",
name="rm-default",
version=1,
)
_insert_table_row(
conn,
_LEGACY_REGISTERED_MODEL_ALIASES,
alias="alias",
version=1,
name="rm-default",
)
_insert_table_row(
conn,
_LEGACY_EVALUATION_DATASETS,
dataset_id="ds-default",
name="Dataset",
schema="schema",
profile="profile",
digest="digest",
created_time=0,
last_update_time=0,
created_by="user",
last_updated_by="user",
)
def _get_workspace_default(column_info):
default = column_info.get("default") or column_info.get("server_default")
if default is None:
return None
value = str(default).strip()
if value.startswith("(") and value.endswith(")"):
value = value[1:-1]
value = value.strip()
value = value.strip("'\"")
if "::" in value:
value = value.split("::", 1)[0]
return value.strip("'\"")
def _add_workspace(conn, name: str, description: str):
conn.execute(
sa.text("INSERT INTO workspaces (name, description) VALUES (:name, :description)"),
{"name": name, "description": description},
)
def _insert_experiment(
conn,
*,
experiment_id: int,
name: str,
workspace: str,
artifact_location: str = "path",
lifecycle_stage: str = "active",
):
with _identity_insert(conn, "experiments"):
conn.execute(
sa.text(
"""
INSERT INTO experiments (
experiment_id,
name,
artifact_location,
lifecycle_stage,
creation_time,
last_update_time,
workspace
)
VALUES (
:experiment_id,
:name,
:artifact_location,
:lifecycle_stage,
:creation_time,
:last_update_time,
:workspace
)
"""
),
{
"experiment_id": experiment_id,
"name": name,
"artifact_location": artifact_location,
"lifecycle_stage": lifecycle_stage,
"creation_time": 0,
"last_update_time": 0,
"workspace": workspace,
},
)
def _insert_run(
conn,
*,
run_uuid: str,
experiment_id: int,
name: str = "run",
artifact_uri: str = "path/artifacts",
):
conn.execute(
sa.text(
"""
INSERT INTO runs (
run_uuid,
name,
source_type,
source_name,
entry_point_name,
user_id,
status,
start_time,
end_time,
source_version,
lifecycle_stage,
artifact_uri,
experiment_id
)
VALUES (
:run_uuid,
:name,
:source_type,
:source_name,
:entry_point_name,
:user_id,
:status,
:start_time,
:end_time,
:source_version,
:lifecycle_stage,
:artifact_uri,
:experiment_id
)
"""
),
{
"run_uuid": run_uuid,
"name": name,
"source_type": "LOCAL",
"source_name": "script.py",
"entry_point_name": "main",
"user_id": "user",
"status": "FINISHED",
"start_time": 0,
"end_time": 1,
"source_version": "abc123",
"lifecycle_stage": "active",
"artifact_uri": artifact_uri,
"experiment_id": experiment_id,
},
)
def _insert_registered_model(
conn,
*,
name: str,
workspace: str,
description: str = "desc",
creation_time: int = 0,
):
conn.execute(
sa.text(
"""
INSERT INTO registered_models (
name,
creation_time,
last_updated_time,
description,
workspace
)
VALUES (
:name,
:creation_time,
:last_updated_time,
:description,
:workspace
)
"""
),
{
"name": name,
"creation_time": creation_time,
"last_updated_time": creation_time,
"description": description,
"workspace": workspace,
},
)
def _insert_model_version(
conn,
*,
name: str,
version: int,
workspace: str,
run_id: str = "run-id",
storage_location: str = "location",
):
conn.execute(
sa.text(
"""
INSERT INTO model_versions (
name,
version,
creation_time,
last_updated_time,
user_id,
current_stage,
description,
source,
run_id,
status,
status_message,
run_link,
storage_location,
workspace
)
VALUES (
:name,
:version,
:creation_time,
:last_updated_time,
:user_id,
:current_stage,
:description,
:source,
:run_id,
:status,
:status_message,
:run_link,
:storage_location,
:workspace
)
"""
),
{
"name": name,
"version": version,
"creation_time": 0,
"last_updated_time": 0,
"user_id": "user",
"current_stage": "None",
"description": "desc",
"source": "source",
"run_id": run_id,
"status": "READY",
"status_message": "message",
"run_link": "link",
"storage_location": storage_location,
"workspace": workspace,
},
)
def _insert_registered_model_tag(
conn,
*,
workspace: str,
name: str,
key: str,
value: str = "value",
):
_insert_table_row(
conn,
_REGISTERED_MODEL_TAGS,
workspace=workspace,
key=key,
value=value,
name=name,
)
def _insert_model_version_tag(
conn,
*,
workspace: str,
name: str,
version: int,
key: str,
value: str = "value",
):
_insert_table_row(
conn,
_MODEL_VERSION_TAGS,
workspace=workspace,
key=key,
value=value,
name=name,
version=version,
)
def _insert_registered_model_alias(
conn,
*,
workspace: str,
name: str,
alias: str,
version: int = 1,
):
_insert_table_row(
conn,
_REGISTERED_MODEL_ALIASES,
workspace=workspace,
name=name,
alias=alias,
version=version,
)
def _insert_evaluation_dataset(
conn,
*,
dataset_id: str,
workspace: str,
name: str = "Dataset",
digest: str = "digest",
):
conn.execute(
sa.insert(_EVALUATION_DATASETS).values(
dataset_id=dataset_id,
name=name,
schema="schema",
profile="profile",
digest=digest,
created_time=0,
last_update_time=0,
created_by="user",
last_updated_by="user",
workspace=workspace,
)
)
_SECRETS = sa.table(
"secrets",
sa.column("secret_id"),
sa.column("secret_name"),
sa.column("encrypted_value"),
sa.column("wrapped_dek"),
sa.column("kek_version"),
sa.column("masked_value"),
sa.column("provider"),
sa.column("auth_config"),
sa.column("description"),
sa.column("created_by"),
sa.column("created_at"),
sa.column("last_updated_by"),
sa.column("last_updated_at"),
sa.column("workspace"),
)
def _insert_secret(
conn,
*,
secret_id: str,
secret_name: str,
workspace: str,
):
conn.execute(
sa.insert(_SECRETS).values(
secret_id=secret_id,
secret_name=secret_name,
encrypted_value=b"encrypted",
wrapped_dek=b"dek",
kek_version=1,
masked_value="***",
provider="openai",
auth_config=None,
description=None,
created_by="user",
created_at=0,
last_updated_by="user",
last_updated_at=0,
workspace=workspace,
)
)
_ENDPOINTS = sa.table(
"endpoints",
sa.column("endpoint_id"),
sa.column("name"),
sa.column("created_by"),
sa.column("created_at"),
sa.column("last_updated_by"),
sa.column("last_updated_at"),
sa.column("workspace"),
)
def _insert_endpoint(
conn,
*,
endpoint_id: str,
name: str,
workspace: str,
):
conn.execute(
sa.insert(_ENDPOINTS).values(
endpoint_id=endpoint_id,
name=name,
created_by="user",
created_at=0,
last_updated_by="user",
last_updated_at=0,
workspace=workspace,
)
)
_MODEL_DEFINITIONS = sa.table(
"model_definitions",
sa.column("model_definition_id"),
sa.column("name"),
sa.column("secret_id"),
sa.column("provider"),
sa.column("model_name"),
sa.column("created_by"),
sa.column("created_at"),
sa.column("last_updated_by"),
sa.column("last_updated_at"),
sa.column("workspace"),
)
def _insert_model_definition(
conn,
*,
model_definition_id: str,
name: str,
workspace: str,
):
conn.execute(
sa.insert(_MODEL_DEFINITIONS).values(
model_definition_id=model_definition_id,
name=name,
secret_id=None,
provider="openai",
model_name="gpt-4",
created_by="user",
created_at=0,
last_updated_by="user",
last_updated_at=0,
workspace=workspace,
)
)
def _fetch_conflicts(conn, table_name: str, columns: tuple[str, ...]):
metadata = sa.MetaData()
table = sa.Table(table_name, metadata, autoload_with=conn)
group_columns = [table.c[column] for column in columns]
stmt = sa.select(*group_columns).group_by(*group_columns).having(sa.func.count() > 1)
return conn.execute(stmt).fetchall()
def test_workspace_migration_upgrade_adds_columns_and_backfills(tmp_path):
engine, config = _prepare_database(tmp_path)
try:
with engine.begin() as conn:
_seed_pre_workspace_entities(conn)
command.upgrade(config, REVISION)
inspector = sa.inspect(engine)
_assert_workspace_columns(inspector, "default")
with engine.connect() as conn:
assert conn.execute(
sa.text(
"SELECT experiment_id, name, workspace FROM experiments ORDER BY experiment_id"
)
).fetchall() == [(1, "exp-default", "default")]
assert conn.execute(
sa.text("SELECT run_uuid, experiment_id FROM runs ORDER BY run_uuid")
).fetchall() == [("run-default", 1)]
assert conn.execute(
sa.text("SELECT name, workspace FROM registered_models")
).fetchall() == [("rm-default", "default")]
assert conn.execute(
sa.text("SELECT name, version, workspace FROM model_versions")
).fetchall() == [("rm-default", 1, "default")]
assert conn.execute(
sa.select(
_REGISTERED_MODEL_TAGS.c.workspace,
_REGISTERED_MODEL_TAGS.c.name,
_REGISTERED_MODEL_TAGS.c.key,
)
).fetchall() == [("default", "rm-default", "tag")]
assert conn.execute(
sa.select(
_MODEL_VERSION_TAGS.c.workspace,
_MODEL_VERSION_TAGS.c.name,
_MODEL_VERSION_TAGS.c.version,
_MODEL_VERSION_TAGS.c.key,
)
).fetchall() == [("default", "rm-default", 1, "tag")]
assert conn.execute(
sa.text("SELECT workspace, name, alias FROM registered_model_aliases")
).fetchall() == [("default", "rm-default", "alias")]
assert conn.execute(
sa.text("SELECT dataset_id, workspace FROM evaluation_datasets")
).fetchall() == [("ds-default", "default")]
assert conn.execute(
sa.text("SELECT name, description FROM workspaces ORDER BY name")
).fetchall() == [("default", "Default workspace for legacy resources")]
pk_registered_models = inspector.get_pk_constraint("registered_models")
assert pk_registered_models["constrained_columns"] == ["workspace", "name"]
pk_model_versions = inspector.get_pk_constraint("model_versions")
assert pk_model_versions["constrained_columns"] == [
"workspace",
"name",
"version",
]
pk_registered_model_tags = inspector.get_pk_constraint("registered_model_tags")
assert pk_registered_model_tags["constrained_columns"] == [
"workspace",
"key",
"name",
]
pk_model_version_tags = inspector.get_pk_constraint("model_version_tags")
assert pk_model_version_tags["constrained_columns"] == [
"workspace",
"key",
"name",
"version",
]
pk_model_aliases = inspector.get_pk_constraint("registered_model_aliases")
assert pk_model_aliases["constrained_columns"] == [
"workspace",
"name",
"alias",
]
try:
unique_experiments = inspector.get_unique_constraints("experiments")
except NotImplementedError:
if inspector.bind.dialect.name == "mssql":
unique_experiments = None
else:
raise
if unique_experiments is not None:
assert any(
{"workspace", "name"} == set(constraint.get("column_names", []))
for constraint in unique_experiments
)
fk_model_versions = inspector.get_foreign_keys("model_versions")
assert any(
fk.get("constrained_columns") == ["workspace", "name"]
and fk.get("referred_table") == "registered_models"
for fk in fk_model_versions
)
assert _has_index(inspector, "experiments", "idx_experiments_workspace", ["workspace"])
assert _has_index(
inspector,
"experiments",
"idx_experiments_workspace_creation_time",
["workspace", "creation_time"],
)
assert _has_index(
inspector, "registered_models", "idx_registered_models_workspace", ["workspace"]
)
assert _has_index(
inspector, "evaluation_datasets", "idx_evaluation_datasets_workspace", ["workspace"]
)
finally:
engine.dispose()
def test_workspace_migration_downgrade_reverts_schema(tmp_path):
engine, config = _prepare_database(tmp_path)
try:
command.upgrade(config, REVISION)
with engine.begin() as conn:
_add_workspace(conn, "team-a", "Team A")
_insert_experiment(conn, experiment_id=1, name="exp-default", workspace="default")
_insert_run(
conn,
run_uuid="run-default",
experiment_id=1,
name="downgrade-validation-run",
)
_insert_experiment(conn, experiment_id=2, name="exp-team-a", workspace="team-a")
command.downgrade(config, PREVIOUS_REVISION)
inspector = sa.inspect(engine)
tables = inspector.get_table_names()
assert "workspaces" not in tables
for table in (
"experiments",
"registered_models",
"model_versions",
"registered_model_tags",
"model_version_tags",
"registered_model_aliases",
"evaluation_datasets",
"webhooks",
"jobs",
):
column_names = {col["name"] for col in inspector.get_columns(table)}
assert "workspace" not in column_names
with engine.connect() as conn:
assert conn.execute(
sa.text("SELECT experiment_id, name FROM experiments ORDER BY experiment_id")
).fetchall() == [(1, "exp-default"), (2, "exp-team-a")]
assert conn.execute(
sa.text("SELECT run_uuid, experiment_id FROM runs ORDER BY run_uuid")
).fetchall() == [("run-default", 1)]
pk_registered_models = inspector.get_pk_constraint("registered_models")
assert pk_registered_models["constrained_columns"] == ["name"]
pk_model_versions = inspector.get_pk_constraint("model_versions")
assert pk_model_versions["constrained_columns"] == ["name", "version"]
pk_registered_model_tags = inspector.get_pk_constraint("registered_model_tags")
assert pk_registered_model_tags["constrained_columns"] == ["key", "name"]
pk_model_version_tags = inspector.get_pk_constraint("model_version_tags")
assert pk_model_version_tags["constrained_columns"] == ["key", "name", "version"]
pk_registered_model_aliases = inspector.get_pk_constraint("registered_model_aliases")
assert pk_registered_model_aliases["constrained_columns"] == ["name", "alias"]
try:
unique_experiments = inspector.get_unique_constraints("experiments")
except NotImplementedError:
if inspector.bind.dialect.name == "mssql":
unique_experiments = None
else:
raise
if unique_experiments is not None:
assert any(
set(constraint.get("column_names", [])) == {"name"}
for constraint in unique_experiments
)
fk_model_versions = inspector.get_foreign_keys("model_versions")
assert any(
fk.get("constrained_columns") == ["name"]
and fk.get("referred_table") == "registered_models"
for fk in fk_model_versions
)
fk_registered_model_tags = inspector.get_foreign_keys("registered_model_tags")
assert any(
fk.get("constrained_columns") == ["name"]
and fk.get("referred_table") == "registered_models"
for fk in fk_registered_model_tags
)
fk_model_version_tags = inspector.get_foreign_keys("model_version_tags")
assert any(
fk.get("constrained_columns") == ["name", "version"]
and fk.get("referred_table") == "model_versions"
for fk in fk_model_version_tags
)
finally:
engine.dispose()
def _setup_experiment_conflict(conn):
_insert_experiment(conn, experiment_id=1, name="duplicate-exp", workspace="default")
_insert_run(conn, run_uuid="run-exp-default", experiment_id=1)
_insert_experiment(conn, experiment_id=2, name="duplicate-exp", workspace="team-a")
def _setup_registered_model_conflict(conn):
_insert_registered_model(conn, name="duplicate-model", workspace="default")
_insert_registered_model(conn, name="duplicate-model", workspace="team-a")
def _setup_model_version_conflict(conn):
_insert_registered_model(conn, name="mv-model", workspace="default")
_insert_registered_model(conn, name="mv-model", workspace="team-a")
_insert_model_version(conn, name="mv-model", version=1, workspace="default")
_insert_model_version(conn, name="mv-model", version=1, workspace="team-a")
def _setup_registered_model_tag_conflict(conn):
_insert_registered_model(conn, name="tag-model", workspace="default")
_insert_registered_model(conn, name="tag-model", workspace="team-a")
_insert_registered_model_tag(conn, workspace="default", name="tag-model", key="tag-key")
_insert_registered_model_tag(conn, workspace="team-a", name="tag-model", key="tag-key")
def _setup_model_version_tag_conflict(conn):
_insert_registered_model(conn, name="mvt-model", workspace="default")
_insert_registered_model(conn, name="mvt-model", workspace="team-a")
_insert_model_version(conn, name="mvt-model", version=1, workspace="default")
_insert_model_version(conn, name="mvt-model", version=1, workspace="team-a")
_insert_model_version_tag(
conn, workspace="default", name="mvt-model", version=1, key="mv-tag-key"
)
_insert_model_version_tag(
conn, workspace="team-a", name="mvt-model", version=1, key="mv-tag-key"
)
def _setup_registered_model_alias_conflict(conn):
_insert_registered_model(conn, name="alias-model", workspace="default")
_insert_registered_model(conn, name="alias-model", workspace="team-a")
_insert_registered_model_alias(conn, workspace="default", name="alias-model", alias="latest")
_insert_registered_model_alias(conn, workspace="team-a", name="alias-model", alias="latest")
def _setup_evaluation_dataset_conflict(conn):
_insert_evaluation_dataset(
conn, dataset_id="ds-default", name="duplicate-ds", workspace="default"
)
_insert_evaluation_dataset(
conn, dataset_id="ds-team-a", name="duplicate-ds", workspace="team-a"
)
def _setup_secret_conflict(conn):
_insert_secret(conn, secret_id="s-default", secret_name="duplicate-secret", workspace="default")
_insert_secret(conn, secret_id="s-team-a", secret_name="duplicate-secret", workspace="team-a")
def _setup_endpoint_conflict(conn):
_insert_endpoint(conn, endpoint_id="e-default", name="duplicate-endpoint", workspace="default")
_insert_endpoint(conn, endpoint_id="e-team-a", name="duplicate-endpoint", workspace="team-a")
def _setup_model_definition_conflict(conn):
_insert_model_definition(
conn, model_definition_id="md-default", name="duplicate-def", workspace="default"
)
_insert_model_definition(
conn, model_definition_id="md-team-a", name="duplicate-def", workspace="team-a"
)
@pytest.mark.parametrize(
("setup_conflict", "expected_fragment", "case_slug"),
[
(_setup_experiment_conflict, "duplicate experiments with the same name", "experiments"),
(
_setup_registered_model_conflict,
"duplicate registered models with the same name",
"models",
),
(
_setup_evaluation_dataset_conflict,
"duplicate evaluation datasets with the same name",
"evaluation_datasets",
),
(
_setup_secret_conflict,
"duplicate secrets with the same name",
"secrets",
),
(
_setup_endpoint_conflict,
"duplicate endpoints with the same name",
"endpoints",
),
(
_setup_model_definition_conflict,
"duplicate model definitions with the same name",
"model_definitions",
),
],
)
def test_workspace_migration_downgrade_detects_conflicts(
tmp_path, setup_conflict, expected_fragment, case_slug
):
case_dir = tmp_path / f"conflict_{case_slug}"
case_dir.mkdir()
engine, config = _prepare_database(case_dir)
try:
command.upgrade(config, REVISION)
with engine.begin() as conn:
_add_workspace(conn, "team-a", "Team A")
setup_conflict(conn)
with pytest.raises(
RuntimeError,
match=re.escape(expected_fragment),
):
command.downgrade(config, PREVIOUS_REVISION)
finally:
engine.dispose()
@pytest.mark.parametrize(
("setup_conflict", "table_name", "columns", "case_slug"),
[
(
_setup_model_version_conflict,
"model_versions",
("name", "version"),
"model_versions",
),
(
_setup_registered_model_tag_conflict,
"registered_model_tags",
("name", "key"),
"registered_model_tags",
),
(
_setup_model_version_tag_conflict,
"model_version_tags",
("name", "version", "key"),
"model_version_tags",
),
(
_setup_registered_model_alias_conflict,
"registered_model_aliases",
("name", "alias"),
"registered_model_aliases",
),
],
)
def test_workspace_migration_conflict_detection_queries(
tmp_path, setup_conflict, table_name, columns, case_slug
):
case_dir = tmp_path / f"conflict_query_{case_slug}"
case_dir.mkdir()
engine, config = _prepare_database(case_dir)
try:
command.upgrade(config, REVISION)
with engine.begin() as conn:
_add_workspace(conn, "team-a", "Team A")
setup_conflict(conn)
conflicts = _fetch_conflicts(conn, table_name, columns)
assert conflicts, f"Expected conflicts for {table_name}, found none"
finally:
engine.dispose()