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

317 lines
11 KiB
Python

import os
import shutil
from pathlib import Path
from unittest import mock
import pytest
import sqlalchemy
import sqlalchemy.orm
from mlflow.entities import Metric, ViewType
from mlflow.exceptions import MlflowException
from mlflow.store.db.utils import _get_routing_session_maker
from mlflow.store.tracking.sqlalchemy_store import SqlAlchemyStore
ARTIFACT_URI = "artifact_folder"
@pytest.fixture
def write_db_uri(tmp_path: Path) -> str:
return f"sqlite:///{tmp_path / 'write.db'}"
@pytest.fixture
def read_db_uri(tmp_path: Path) -> str:
return f"sqlite:///{tmp_path / 'read.db'}"
@pytest.fixture
def store_with_replica(tmp_path: Path, write_db_uri, read_db_uri):
artifact_uri = tmp_path / "artifacts"
artifact_uri.mkdir(exist_ok=True)
write_store = SqlAlchemyStore(write_db_uri, str(artifact_uri))
write_store.engine.dispose()
shutil.copy(tmp_path / "write.db", tmp_path / "read.db")
return SqlAlchemyStore(write_db_uri, str(artifact_uri), read_db_uri=read_db_uri)
@pytest.fixture
def store_no_replica(tmp_path: Path, write_db_uri):
artifact_uri = tmp_path / "artifacts"
artifact_uri.mkdir(exist_ok=True)
return SqlAlchemyStore(write_db_uri, str(artifact_uri))
# --- TestManagedSessionMakerReadOnlyParam ---
def test_managed_session_maker_accepts_read_only_false(store_no_replica):
with store_no_replica.ManagedSessionMaker(read_only=False) as session:
assert session is not None
def test_managed_session_maker_accepts_read_only_true(store_no_replica):
with store_no_replica.ManagedSessionMaker(read_only=True) as session:
assert session is not None
def test_managed_session_maker_default_no_read_only(store_no_replica):
with store_no_replica.ManagedSessionMaker() as session:
assert session is not None
# --- TestDualEngineSetup ---
def test_no_replica_uses_single_engine(store_no_replica):
assert store_no_replica.read_engine is None
def test_replica_creates_separate_engine(store_with_replica, write_db_uri, read_db_uri):
assert store_with_replica.read_engine is not None
assert store_with_replica.engine is not store_with_replica.read_engine
def test_same_uri_skips_replica(tmp_path: Path, write_db_uri):
artifact_uri = tmp_path / "artifacts"
artifact_uri.mkdir(exist_ok=True)
store = SqlAlchemyStore(write_db_uri, str(artifact_uri), read_db_uri=write_db_uri)
assert store.read_engine is None
def test_none_read_uri_skips_replica(store_no_replica):
assert store_no_replica.read_engine is None
# --- TestRoutingSessionMaker ---
def test_routing_read_only_true_uses_read_session():
write_engine = sqlalchemy.create_engine("sqlite:///:memory:")
read_engine = sqlalchemy.create_engine("sqlite:///:memory:")
write_sm = sqlalchemy.orm.sessionmaker(bind=write_engine)
read_sm = sqlalchemy.orm.sessionmaker(bind=read_engine)
routing_maker = _get_routing_session_maker(write_sm, read_sm, "sqlite")
with routing_maker(read_only=True) as session:
assert session.get_bind() is read_engine
with routing_maker(read_only=False) as session:
assert session.get_bind() is write_engine
with routing_maker() as session:
# Default is read_only=True
assert session.get_bind() is read_engine
# --- TestReadWriteRouting ---
def test_write_goes_to_primary(store_with_replica):
store_with_replica.create_experiment("test_write")
with store_with_replica.ManagedSessionMaker(read_only=False) as session:
from mlflow.store.tracking.dbmodels.models import SqlExperiment
exp = session.query(SqlExperiment).filter_by(name="test_write").first()
assert exp is not None
def test_read_from_replica(store_with_replica, tmp_path):
read_store = SqlAlchemyStore(
str(store_with_replica.read_engine.url),
str(tmp_path / "artifacts"),
)
read_store.create_experiment("only_on_replica")
exp = store_with_replica.get_experiment_by_name("only_on_replica")
assert exp is not None
assert exp.name == "only_on_replica"
def test_search_experiments_uses_replica(store_with_replica, tmp_path):
read_store = SqlAlchemyStore(
str(store_with_replica.read_engine.url),
str(tmp_path / "artifacts"),
)
read_store.create_experiment("replica_exp")
results = store_with_replica.search_experiments(view_type=ViewType.ALL)
names = [e.name for e in results]
assert "replica_exp" in names
def test_get_run_uses_replica(store_with_replica, tmp_path):
read_store = SqlAlchemyStore(
str(store_with_replica.read_engine.url),
str(tmp_path / "artifacts"),
)
exp_id = read_store.create_experiment("run_test")
run = read_store.create_run(exp_id, "user", 0, [], None)
run_id = run.info.run_id
fetched = store_with_replica.get_run(run_id)
assert fetched.info.run_id == run_id
# --- TestBackwardCompatibility ---
def test_backward_compat_create_and_read_experiment(store_no_replica):
exp_id = store_no_replica.create_experiment("compat_test")
exp = store_no_replica.get_experiment(exp_id)
assert exp.name == "compat_test"
def test_backward_compat_create_and_search_runs(store_no_replica):
exp_id = store_no_replica.create_experiment("run_compat")
run = store_no_replica.create_run(exp_id, "user", 0, [], None)
fetched = store_no_replica.get_run(run.info.run_id)
assert fetched.info.run_id == run.info.run_id
def test_backward_compat_log_and_get_metrics(store_no_replica):
exp_id = store_no_replica.create_experiment("metric_compat")
run = store_no_replica.create_run(exp_id, "user", 0, [], None)
store_no_replica.log_metric(run.info.run_id, Metric("acc", 0.95, 1, 0))
history = store_no_replica.get_metric_history(run.info.run_id, "acc")
assert len(history) == 1
assert history[0].value == 0.95
# --- TestServerWiring ---
def test_run_server_passes_read_uri_env_var():
from mlflow.server import _run_server
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
with mock.patch("mlflow.server._exec_cmd") as mock_exec:
mock_exec.side_effect = SystemExit(0)
try:
_run_server(
file_store_path="sqlite:///test.db",
read_replica_backend_store_uri="sqlite:///read.db",
registry_store_uri=None,
default_artifact_root="/tmp/artifacts",
serve_artifacts=False,
artifacts_only=False,
artifacts_destination=None,
host="127.0.0.1",
port=5000,
)
except SystemExit:
pass
call_kwargs = mock_exec.call_args
env_map = call_kwargs[1].get("extra_env", {}) if call_kwargs[1] else {}
if not env_map and call_kwargs[0]:
for arg in call_kwargs[0]:
if isinstance(arg, dict):
env_map = arg
break
assert READ_REPLICA_BACKEND_STORE_URI_ENV_VAR in env_map
assert env_map[READ_REPLICA_BACKEND_STORE_URI_ENV_VAR] == "sqlite:///read.db"
def test_run_server_omits_read_uri_when_none():
from mlflow.server import _run_server
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
with mock.patch("mlflow.server._exec_cmd") as mock_exec:
mock_exec.side_effect = SystemExit(0)
try:
_run_server(
file_store_path="sqlite:///test.db",
registry_store_uri=None,
default_artifact_root="/tmp/artifacts",
serve_artifacts=False,
artifacts_only=False,
artifacts_destination=None,
host="127.0.0.1",
port=5000,
)
except SystemExit:
pass
call_kwargs = mock_exec.call_args
env_map = call_kwargs[1].get("extra_env", {}) if call_kwargs[1] else {}
if not env_map and call_kwargs[0]:
for arg in call_kwargs[0]:
if isinstance(arg, dict):
env_map = arg
break
assert READ_REPLICA_BACKEND_STORE_URI_ENV_VAR not in env_map
def test_initialize_backend_stores_sets_env_var():
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
from mlflow.server.handlers import initialize_backend_stores
# `initialize_backend_stores` mutates `os.environ` directly. Don't use `monkeypatch.delenv`
# for cleanup: it records the env var's *current* value (set by `initialize_backend_stores`)
# for rollback, which causes the value to be restored at teardown and leak into later tests.
os.environ.pop(
READ_REPLICA_BACKEND_STORE_URI_ENV_VAR, None
) # clint: disable=os-environ-delete-in-test
try:
with (
mock.patch("mlflow.server.handlers._get_tracking_store"),
mock.patch("mlflow.server.handlers._get_model_registry_store"),
):
initialize_backend_stores(
backend_store_uri="sqlite:///test.db",
read_replica_backend_store_uri="sqlite:///read.db",
)
assert os.environ.get(READ_REPLICA_BACKEND_STORE_URI_ENV_VAR) == "sqlite:///read.db"
finally:
os.environ.pop(
READ_REPLICA_BACKEND_STORE_URI_ENV_VAR, None
) # clint: disable=os-environ-delete-in-test
def test_get_sqlalchemy_store_reads_env_var(tmp_path, monkeypatch):
from mlflow.server.constants import READ_REPLICA_BACKEND_STORE_URI_ENV_VAR
from mlflow.server.handlers import TrackingStoreRegistryWrapper
write_uri = f"sqlite:///{tmp_path / 'write.db'}"
read_uri = f"sqlite:///{tmp_path / 'read.db'}"
init_store = SqlAlchemyStore(write_uri, str(tmp_path / "artifacts"))
init_store.engine.dispose()
shutil.copy(tmp_path / "write.db", tmp_path / "read.db")
monkeypatch.setenv(READ_REPLICA_BACKEND_STORE_URI_ENV_VAR, read_uri)
store = TrackingStoreRegistryWrapper._get_sqlalchemy_store(
write_uri, str(tmp_path / "artifacts")
)
assert store.read_engine is not None
# --- TestReadOnlyAnnotations ---
# With read_only=True as the default, write methods must explicitly pass read_only=False.
# The before_flush listener (active when _MLFLOW_TESTING is set) enforces this at runtime.
def test_write_on_read_only_session_raises(store_with_replica, monkeypatch):
monkeypatch.setenv("_MLFLOW_TESTING", "true")
from mlflow.store.tracking.dbmodels.models import SqlExperiment
def _do_write():
with store_with_replica.ManagedSessionMaker() as session:
session.add(SqlExperiment(name="should_fail", lifecycle_stage="active"))
session.flush()
with pytest.raises(MlflowException, match="Write operation detected"):
_do_write()
def test_write_on_write_session_succeeds(store_with_replica, monkeypatch):
monkeypatch.setenv("_MLFLOW_TESTING", "true")
from mlflow.store.tracking.dbmodels.models import SqlExperiment
with store_with_replica.ManagedSessionMaker(read_only=False) as session:
session.add(SqlExperiment(name="should_succeed", lifecycle_stage="active"))
session.flush()