471 lines
19 KiB
Python
471 lines
19 KiB
Python
import hashlib
|
|
import logging
|
|
import os
|
|
import re
|
|
import sqlite3
|
|
import tempfile
|
|
import time
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
import sqlalchemy
|
|
from alembic.migration import MigrationContext
|
|
from alembic.script import ScriptDirectory
|
|
from packaging.version import Version
|
|
from sqlalchemy import event, sql
|
|
|
|
# We need to import sqlalchemy.pool to convert poolclass string to class object
|
|
from sqlalchemy.pool import (
|
|
AssertionPool,
|
|
AsyncAdaptedQueuePool,
|
|
NullPool,
|
|
QueuePool,
|
|
SingletonThreadPool,
|
|
StaticPool,
|
|
)
|
|
|
|
# CRITICAL: Import ORM modules to register all table metadata with Base.metadata.
|
|
# _all_tables_exist() depends on Base.metadata.tables being fully populated.
|
|
# If these imports are removed, Base.metadata.tables will be incomplete and
|
|
# _all_tables_exist() will return True even when tables are missing, silently
|
|
# skipping DB initialization/migration.
|
|
import mlflow.store.model_registry.dbmodels.models # noqa: F401
|
|
import mlflow.store.tracking.dbmodels.models # noqa: F401
|
|
import mlflow.store.workspace.dbmodels.models # noqa: F401
|
|
from mlflow.environment_variables import (
|
|
MLFLOW_MYSQL_SSL_CA,
|
|
MLFLOW_MYSQL_SSL_CERT,
|
|
MLFLOW_MYSQL_SSL_KEY,
|
|
MLFLOW_SQLALCHEMYSTORE_ECHO,
|
|
MLFLOW_SQLALCHEMYSTORE_MAX_OVERFLOW,
|
|
MLFLOW_SQLALCHEMYSTORE_POOL_RECYCLE,
|
|
MLFLOW_SQLALCHEMYSTORE_POOL_SIZE,
|
|
MLFLOW_SQLALCHEMYSTORE_POOLCLASS,
|
|
)
|
|
from mlflow.exceptions import MlflowException
|
|
from mlflow.protos.databricks_pb2 import (
|
|
BAD_REQUEST,
|
|
INTERNAL_ERROR,
|
|
TEMPORARILY_UNAVAILABLE,
|
|
)
|
|
from mlflow.store.db.base_sql_model import Base
|
|
from mlflow.store.db.db_types import SQLITE
|
|
from mlflow.store.tracking.dbmodels.initial_models import Base as InitialBase
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
MAX_RETRY_COUNT = 10
|
|
|
|
|
|
def _get_package_dir():
|
|
"""Returns directory containing MLflow python package."""
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
return os.path.normpath(os.path.join(current_dir, os.pardir, os.pardir))
|
|
|
|
|
|
def _all_tables_exist(engine):
|
|
# Check if all ORM-defined tables exist in the database.
|
|
# Derived from Base.metadata.tables so that newly added ORM models are
|
|
# automatically included without requiring a manual update here.
|
|
# Using issubset() instead of equality so that additional tables added by migrations
|
|
# don't cause this check to fail. This prevents unnecessary calls to _initialize_tables
|
|
# which can cause migration errors like "Can't locate revision identified by 'xxx'".
|
|
expected_tables = set(Base.metadata.tables)
|
|
actual_tables = {
|
|
t for t in sqlalchemy.inspect(engine).get_table_names() if not t.startswith("alembic_")
|
|
}
|
|
return expected_tables.issubset(actual_tables)
|
|
|
|
|
|
def _is_empty_database(engine):
|
|
# Alembic's bookkeeping table is ignored so a previously failed upgrade attempt
|
|
# doesn't make the DB look populated.
|
|
return all(t.startswith("alembic_") for t in sqlalchemy.inspect(engine).get_table_names())
|
|
|
|
|
|
def _initialize_tables(engine):
|
|
_logger.info("Creating initial MLflow database tables...")
|
|
InitialBase.metadata.create_all(engine)
|
|
_upgrade_db(engine)
|
|
|
|
|
|
def _safe_initialize_tables(engine: sqlalchemy.engine.Engine) -> None:
|
|
from mlflow.utils.file_utils import ExclusiveFileLock
|
|
|
|
if os.name == "nt":
|
|
if not _all_tables_exist(engine):
|
|
_initialize_tables(engine)
|
|
return
|
|
|
|
url_hash = hashlib.md5(
|
|
str(engine.url).encode("utf-8"),
|
|
usedforsecurity=False,
|
|
).hexdigest()
|
|
with ExclusiveFileLock(f"{tempfile.gettempdir()}/db_init_lock-{url_hash}"):
|
|
if not _all_tables_exist(engine):
|
|
_initialize_tables(engine)
|
|
|
|
|
|
def _get_latest_schema_revision():
|
|
"""Get latest schema revision as a string."""
|
|
# We aren't executing any commands against a DB, so we leave the DB URL unspecified
|
|
config = _get_alembic_config(db_url="")
|
|
script = ScriptDirectory.from_config(config)
|
|
heads = script.get_heads()
|
|
if len(heads) != 1:
|
|
raise MlflowException(
|
|
f"Migration script directory was in unexpected state. Got {len(heads)} head "
|
|
f"database versions but expected only 1. Found versions: {heads}"
|
|
)
|
|
return heads[0]
|
|
|
|
|
|
def _verify_schema(engine):
|
|
head_revision = _get_latest_schema_revision()
|
|
current_rev = _get_schema_version(engine)
|
|
if current_rev != head_revision:
|
|
raise MlflowException(
|
|
f"Detected out-of-date database schema (found version {current_rev}, "
|
|
f"but expected {head_revision}). Take a backup of your database, then run "
|
|
"'mlflow db upgrade <database_uri>' "
|
|
"to migrate your database to the latest schema. NOTE: schema migration may "
|
|
"result in database downtime - please consult your database's documentation for "
|
|
"more detail."
|
|
)
|
|
|
|
|
|
def _get_managed_session_maker(SessionMaker, db_type):
|
|
"""
|
|
Creates a factory for producing exception-safe SQLAlchemy sessions that are made available
|
|
using a context manager. Any session produced by this factory is automatically committed
|
|
if no exceptions are encountered within its associated context. If an exception is
|
|
encountered, the session is rolled back. Finally, any session produced by this factory is
|
|
automatically closed when the session's associated context is exited.
|
|
"""
|
|
|
|
# Default to read_only=True to avoid accidentally treating read query as write
|
|
# and send to the main workers. The test time validation detects when the
|
|
# actual write query is marked read-only.
|
|
@contextmanager
|
|
def make_managed_session(read_only=True):
|
|
"""Provide a transactional scope around a series of operations."""
|
|
with SessionMaker() as session:
|
|
try:
|
|
if db_type == SQLITE:
|
|
session.execute(sql.text("PRAGMA foreign_keys = ON;"))
|
|
session.execute(sql.text("PRAGMA busy_timeout = 20000;"))
|
|
session.execute(sql.text("PRAGMA case_sensitive_like = true;"))
|
|
|
|
# Validation that only triggers while testing to make sure the
|
|
# read_only flag is properly set.
|
|
if read_only and os.environ.get("PYTEST_CURRENT_TEST"):
|
|
|
|
def _reject_flush(session, flush_context, instances):
|
|
raise MlflowException(
|
|
"Write operation detected on a read-only session. "
|
|
"ManagedSessionMaker creates read-only sessions "
|
|
"by default. Pass read_only=False to the constructor "
|
|
"to mark it as a write operation.",
|
|
error_code=INTERNAL_ERROR,
|
|
)
|
|
|
|
event.listen(session, "before_flush", _reject_flush)
|
|
yield session
|
|
session.commit()
|
|
except MlflowException:
|
|
session.rollback()
|
|
raise
|
|
except sqlalchemy.exc.OperationalError as e:
|
|
session.rollback()
|
|
_logger.exception(
|
|
"SQLAlchemy database error. The following exception is caught.\n%s",
|
|
e,
|
|
)
|
|
raise MlflowException(message=e, error_code=TEMPORARILY_UNAVAILABLE) from e
|
|
except sqlalchemy.exc.SQLAlchemyError as e:
|
|
session.rollback()
|
|
raise MlflowException(message=e, error_code=BAD_REQUEST) from e
|
|
except Exception as e:
|
|
session.rollback()
|
|
raise MlflowException(message=e, error_code=INTERNAL_ERROR) from e
|
|
|
|
return make_managed_session
|
|
|
|
|
|
def _get_routing_session_maker(write_session_maker, read_session_maker, db_type):
|
|
"""
|
|
Creates a routing-aware managed session factory that directs read operations to a
|
|
read replica and write operations to the primary database.
|
|
|
|
When a read replica URI is configured, read-only operations (marked with
|
|
``read_only=True``) use the read replica's session, while all other operations
|
|
use the primary (write) session. If no read replica is configured, both session
|
|
makers should be identical and all operations go to the primary database.
|
|
|
|
Args:
|
|
write_session_maker: SQLAlchemy ``sessionmaker`` bound to the primary (write) engine.
|
|
read_session_maker: SQLAlchemy ``sessionmaker`` bound to the read replica engine.
|
|
db_type: Database type string (e.g. ``"sqlite"``) forwarded to
|
|
``_get_managed_session_maker`` for dialect-specific session configuration.
|
|
|
|
Returns:
|
|
A context-manager factory accepting an optional ``read_only`` keyword argument.
|
|
"""
|
|
_write = _get_managed_session_maker(write_session_maker, db_type)
|
|
_read = _get_managed_session_maker(read_session_maker, db_type)
|
|
|
|
@contextmanager
|
|
def make_managed_session(read_only=True):
|
|
with (_read if read_only else _write)(read_only=read_only) as session:
|
|
yield session
|
|
|
|
return make_managed_session
|
|
|
|
|
|
def _get_alembic_config(db_url, alembic_dir=None):
|
|
"""
|
|
Constructs an alembic Config object referencing the specified database and migration script
|
|
directory.
|
|
|
|
Args:
|
|
db_url: Database URL, like sqlite:///<absolute-path-to-local-db-file>. See
|
|
https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls for a full list of
|
|
valid database URLs.
|
|
alembic_dir: Path to migration script directory. Uses canonical migration script
|
|
directory under mlflow/alembic if unspecified. TODO: remove this argument in MLflow 1.1,
|
|
as it's only used to run special migrations for pre-1.0 users to remove duplicate
|
|
constraint names.
|
|
"""
|
|
from alembic.config import Config
|
|
|
|
final_alembic_dir = (
|
|
os.path.join(_get_package_dir(), "store", "db_migrations")
|
|
if alembic_dir is None
|
|
else alembic_dir
|
|
)
|
|
# Escape any '%' that appears in a db_url. This could be in a password,
|
|
# url, or anything that is part of a potentially complex database url
|
|
db_url = db_url.replace("%", "%%")
|
|
config = Config(os.path.join(final_alembic_dir, "alembic.ini"))
|
|
config.set_main_option("script_location", final_alembic_dir)
|
|
config.set_main_option("sqlalchemy.url", db_url)
|
|
return config
|
|
|
|
|
|
def _check_sqlite_version(db_url: str) -> None:
|
|
"""
|
|
Check if SQLite version supports required features.
|
|
|
|
MLflow requires SQLite 3.31.0 or higher for computed columns support.
|
|
Raises MlflowException if the version is too old.
|
|
"""
|
|
if not db_url.startswith("sqlite:"):
|
|
return
|
|
|
|
version_str = sqlite3.sqlite_version
|
|
try:
|
|
sqlite_version = Version(version_str)
|
|
except Exception:
|
|
return
|
|
min_version_str = "3.31.0"
|
|
if sqlite_version < Version(min_version_str):
|
|
raise MlflowException(
|
|
f"MLflow requires SQLite >= {min_version_str} for SQL based "
|
|
f"store, but found {version_str}. Please upgrade your SQLite. "
|
|
f"See https://www.sqlite.org/download.html for installation options."
|
|
)
|
|
|
|
|
|
def _upgrade_db(engine):
|
|
"""
|
|
Upgrade the schema of an MLflow tracking database to the latest supported version.
|
|
Note that schema migrations can be slow and are not guaranteed to be transactional -
|
|
we recommend taking a backup of your database before running migrations.
|
|
|
|
Args:
|
|
url: Database URL, like sqlite:///<absolute-path-to-local-db-file>. See
|
|
https://docs.sqlalchemy.org/en/13/core/engines.html#database-urls for a full list of
|
|
valid database URLs.
|
|
"""
|
|
# alembic adds significant import time, so we import it lazily
|
|
from alembic import command
|
|
|
|
db_url = str(engine.url)
|
|
# Check SQLite version before running migrations
|
|
_check_sqlite_version(db_url)
|
|
_logger.info("Updating database tables")
|
|
config = _get_alembic_config(db_url)
|
|
# Initialize a shared connection to be used for the database upgrade, ensuring that
|
|
# any connection-dependent state (e.g., the state of an in-memory database) is preserved
|
|
# for reference by the upgrade routine. For more information, see
|
|
# https://alembic.sqlalchemy.org/en/latest/cookbook.html#sharing-a-
|
|
# connection-with-a-series-of-migration-commands-and-environments
|
|
with engine.begin() as connection:
|
|
config.attributes["connection"] = connection
|
|
command.upgrade(config, "heads")
|
|
|
|
|
|
def _get_schema_version(engine):
|
|
with engine.connect() as connection:
|
|
mc = MigrationContext.configure(connection)
|
|
return mc.get_current_revision()
|
|
|
|
|
|
def _make_parent_dirs_if_sqlite(db_uri: str) -> None:
|
|
"""Create parent directories for SQLite database file if they don't exist."""
|
|
if not db_uri.startswith("sqlite:///"):
|
|
return
|
|
# SQLite URI format: sqlite:///path/to/file.db (relative) or sqlite:////abs/path (Unix)
|
|
# Remove the 'sqlite:///' prefix to get the path
|
|
# Skip in-memory databases (:memory: or empty path)
|
|
db_path = db_uri.removeprefix("sqlite:///")
|
|
if db_path and db_path != ":memory:":
|
|
Path(db_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def create_sqlalchemy_engine_with_retry(db_uri):
|
|
_make_parent_dirs_if_sqlite(db_uri)
|
|
attempts = 0
|
|
while True:
|
|
attempts += 1
|
|
engine = create_sqlalchemy_engine(db_uri)
|
|
try:
|
|
sqlalchemy.inspect(engine)
|
|
return engine
|
|
except Exception as e:
|
|
if attempts < MAX_RETRY_COUNT:
|
|
sleep_duration = 0.1 * ((2**attempts) - 1)
|
|
_logger.warning(
|
|
"SQLAlchemy engine could not be created. The following exception is caught.\n"
|
|
"%s\nOperation will be retried in %.1f seconds",
|
|
e,
|
|
sleep_duration,
|
|
)
|
|
time.sleep(sleep_duration)
|
|
continue
|
|
raise
|
|
|
|
|
|
def create_sqlalchemy_engine(db_uri):
|
|
pool_size = MLFLOW_SQLALCHEMYSTORE_POOL_SIZE.get()
|
|
pool_max_overflow = MLFLOW_SQLALCHEMYSTORE_MAX_OVERFLOW.get()
|
|
pool_recycle = MLFLOW_SQLALCHEMYSTORE_POOL_RECYCLE.get()
|
|
echo = MLFLOW_SQLALCHEMYSTORE_ECHO.get()
|
|
poolclass = MLFLOW_SQLALCHEMYSTORE_POOLCLASS.get()
|
|
kwargs = {}
|
|
# Send argument only if they have been injected.
|
|
# Some engine does not support them (for example sqllite)
|
|
if pool_size:
|
|
kwargs["pool_size"] = pool_size
|
|
if pool_max_overflow:
|
|
kwargs["max_overflow"] = pool_max_overflow
|
|
if pool_recycle:
|
|
kwargs["pool_recycle"] = pool_recycle
|
|
if echo:
|
|
kwargs["echo"] = echo
|
|
if poolclass:
|
|
pool_class_map = {
|
|
"AssertionPool": AssertionPool,
|
|
"AsyncAdaptedQueuePool": AsyncAdaptedQueuePool,
|
|
"NullPool": NullPool,
|
|
"QueuePool": QueuePool,
|
|
"SingletonThreadPool": SingletonThreadPool,
|
|
"StaticPool": StaticPool,
|
|
}
|
|
try:
|
|
# FallbackAsyncAdaptedQueuePool was removed in SQLAlchemy 2.1
|
|
from sqlalchemy.pool import FallbackAsyncAdaptedQueuePool
|
|
|
|
pool_class_map["FallbackAsyncAdaptedQueuePool"] = FallbackAsyncAdaptedQueuePool
|
|
except ImportError:
|
|
pass
|
|
if poolclass not in pool_class_map:
|
|
list_str = " ".join(pool_class_map.keys())
|
|
err_str = (
|
|
f"Invalid poolclass parameter: {poolclass}. Set environment variable "
|
|
f"poolclass to empty or one of the following values: {list_str}"
|
|
)
|
|
_logger.warning(err_str)
|
|
raise ValueError(err_str)
|
|
kwargs["poolclass"] = pool_class_map[poolclass]
|
|
if kwargs:
|
|
_logger.info("Create SQLAlchemy engine with pool options %s", kwargs)
|
|
|
|
# Handle MySQL SSL certificates via connect_args
|
|
if db_uri.startswith("mysql"):
|
|
connect_args = {
|
|
k: v
|
|
for k, v in {
|
|
"ssl_ca": MLFLOW_MYSQL_SSL_CA.get(),
|
|
"ssl_cert": MLFLOW_MYSQL_SSL_CERT.get(),
|
|
"ssl_key": MLFLOW_MYSQL_SSL_KEY.get(),
|
|
}.items()
|
|
if v
|
|
}
|
|
if connect_args:
|
|
kwargs["connect_args"] = connect_args
|
|
|
|
engine = sqlalchemy.create_engine(db_uri, pool_pre_ping=True, **kwargs)
|
|
|
|
# Register custom functions for SQLite
|
|
if db_uri.startswith("sqlite"):
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _register_sqlite_functions(dbapi_conn, connection_record):
|
|
# Register REGEXP function to enable RLIKE operator support
|
|
def regexp(pattern, string):
|
|
"""Custom REGEXP function for SQLite that uses Python's re module."""
|
|
if string is None or pattern is None:
|
|
return False
|
|
try:
|
|
return re.search(pattern, string) is not None
|
|
except re.error:
|
|
return False
|
|
|
|
dbapi_conn.create_function("regexp", 2, regexp)
|
|
|
|
# Register PERCENTILE aggregate function (PERCENTILE_CONT with linear interpolation)
|
|
class PercentileAggregate:
|
|
def __init__(self):
|
|
self.values = []
|
|
self.percentile_value = None
|
|
|
|
def step(self, value, percentile):
|
|
if value is not None:
|
|
self.values.append(float(value))
|
|
if self.percentile_value is None and percentile is not None:
|
|
self.percentile_value = float(percentile)
|
|
|
|
def finalize(self):
|
|
if not self.values or self.percentile_value is None:
|
|
return None
|
|
|
|
sorted_values = sorted(self.values)
|
|
n = len(sorted_values)
|
|
percentile_ratio = self.percentile_value / 100.0
|
|
|
|
# Linear interpolation: index = percentile_ratio * (n - 1)
|
|
index = percentile_ratio * (n - 1)
|
|
lower_idx = int(index)
|
|
upper_idx = min(lower_idx + 1, n - 1)
|
|
fraction = index - lower_idx
|
|
|
|
return sorted_values[lower_idx] + fraction * (
|
|
sorted_values[upper_idx] - sorted_values[lower_idx]
|
|
)
|
|
|
|
dbapi_conn.create_aggregate("percentile", 2, PercentileAggregate)
|
|
|
|
# SQLAlchemy's MSSQL dialect calls the base fetch_clause() which generates
|
|
# "FETCH FIRST n ROWS ONLY" but SQL Server requires "FETCH NEXT n ROWS ONLY".
|
|
# Register an event listener to rewrite the generated SQL before execution.
|
|
if db_uri.startswith("mssql"):
|
|
|
|
@event.listens_for(engine, "before_cursor_execute", retval=True)
|
|
def _fix_mssql_fetch_syntax(conn, cursor, statement, parameters, context, executemany):
|
|
if " FETCH FIRST " in statement:
|
|
statement = statement.replace(" FETCH FIRST ", " FETCH NEXT ")
|
|
return statement, parameters
|
|
|
|
return engine
|