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

371 lines
16 KiB
Python

from __future__ import annotations
import logging
from threading import Lock
from typing import Iterable
from cachetools import TTLCache
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker
from mlflow.entities.workspace import TraceArchivalConfig, Workspace, WorkspaceDeletionMode
from mlflow.environment_variables import (
MLFLOW_WORKSPACE_ARTIFACT_ROOT_CACHE_CAPACITY,
MLFLOW_WORKSPACE_ARTIFACT_ROOT_CACHE_TTL_SECONDS,
)
from mlflow.exceptions import MlflowException
from mlflow.protos.databricks_pb2 import (
INVALID_STATE,
RESOURCE_ALREADY_EXISTS,
RESOURCE_DOES_NOT_EXIST,
)
from mlflow.store.model_registry.dbmodels.models import SqlRegisteredModel, SqlWebhook
from mlflow.store.tracking.dbmodels.models import (
SqlEvaluationDataset,
SqlExperiment,
SqlGatewayBudgetPolicy,
SqlGatewayEndpoint,
SqlGatewayGuardrail,
SqlGatewayModelDefinition,
SqlGatewaySecret,
SqlJob,
)
from mlflow.store.workspace.abstract_store import (
AbstractStore,
ResolvedTraceArchivalConfig,
WorkspaceNameValidator,
)
from mlflow.store.workspace.dbmodels import SqlWorkspace
from mlflow.utils.uri import extract_db_type_from_uri
from mlflow.utils.validation import (
_validate_trace_archival_repository_support,
_validate_trace_archival_retention_string,
)
from mlflow.utils.workspace_utils import DEFAULT_WORKSPACE_NAME
_logger = logging.getLogger(__name__)
_CACHE_MISS = object()
# Root workspace-aware ORM models whose workspace column must be handled before deleting a
# workspace. SqlRegisteredModel is first because its onupdate="CASCADE" foreign keys
# automatically propagate the change to model_versions, registered_model_tags,
# model_version_tags, and registered_model_aliases.
_WORKSPACE_ROOT_MODELS = [
SqlRegisteredModel,
SqlExperiment,
SqlEvaluationDataset,
SqlWebhook,
SqlGatewaySecret,
SqlGatewayEndpoint,
SqlGatewayModelDefinition,
SqlGatewayBudgetPolicy,
SqlGatewayGuardrail,
SqlJob,
]
class SqlAlchemyStore(AbstractStore):
"""SQL-backed workspace store implementation."""
def __init__(self, db_uri: str):
from mlflow.store.db import utils as db_utils
self._workspace_uri = db_uri
self._db_type = extract_db_type_from_uri(db_uri)
self._engine = db_utils.create_sqlalchemy_engine_with_retry(db_uri)
db_utils._safe_initialize_tables(self._engine)
session_factory = sessionmaker(bind=self._engine)
self.ManagedSessionMaker = db_utils._get_managed_session_maker(
session_factory, self._db_type
)
# Use a per-process TTL cache to reduce DB lookups; values converge via TTL expiration.
self._artifact_root_cache: TTLCache[str, str | None] = TTLCache(
maxsize=MLFLOW_WORKSPACE_ARTIFACT_ROOT_CACHE_CAPACITY.get(),
ttl=MLFLOW_WORKSPACE_ARTIFACT_ROOT_CACHE_TTL_SECONDS.get(),
)
self._artifact_root_cache_lock = Lock()
self._trace_archival_config_cache: TTLCache[str, tuple[str | None, str | None]] = TTLCache(
maxsize=MLFLOW_WORKSPACE_ARTIFACT_ROOT_CACHE_CAPACITY.get(),
ttl=MLFLOW_WORKSPACE_ARTIFACT_ROOT_CACHE_TTL_SECONDS.get(),
)
self._trace_archival_config_cache_lock = Lock()
@staticmethod
def _validate_workspace_trace_archival_config(
trace_archival_location: str | None,
trace_archival_retention: str | None,
) -> tuple[str | None, str | None]:
validated_location = (
_validate_trace_archival_repository_support(
trace_archival_location,
parameter_name="trace_archival_location",
)
if trace_archival_location
else None
)
validated_retention = (
_validate_trace_archival_retention_string(trace_archival_retention)
if trace_archival_retention
else None
)
return validated_location, validated_retention
def list_workspaces(self) -> Iterable[Workspace]:
with self.ManagedSessionMaker() as session:
rows = session.query(SqlWorkspace).order_by(SqlWorkspace.name.asc()).all()
return [row.to_mlflow_entity() for row in rows]
def get_workspace(self, workspace_name: str) -> Workspace:
with self.ManagedSessionMaker() as session:
workspace = self._get_workspace(session, workspace_name)
return workspace.to_mlflow_entity()
def create_workspace(self, workspace: Workspace) -> Workspace:
WorkspaceNameValidator.validate(workspace.name)
trace_archival_location, trace_archival_retention = (
self._validate_workspace_trace_archival_config(
workspace.trace_archival_location,
workspace.trace_archival_retention,
)
)
with self.ManagedSessionMaker(read_only=False) as session:
try:
entity = SqlWorkspace(
name=workspace.name,
description=workspace.description,
default_artifact_root=workspace.default_artifact_root or None,
trace_archival_location=trace_archival_location,
trace_archival_retention=trace_archival_retention,
)
session.add(entity)
session.flush()
workspace_entity = entity.to_mlflow_entity()
except IntegrityError as exc:
raise MlflowException(
f"Workspace '{workspace.name}' already exists. Error: {exc}",
RESOURCE_ALREADY_EXISTS,
) from exc
# Only update cache after the transaction has successfully committed.
with self._artifact_root_cache_lock:
self._artifact_root_cache[workspace.name] = workspace_entity.default_artifact_root
with self._trace_archival_config_cache_lock:
self._trace_archival_config_cache[workspace.name] = (
workspace_entity.trace_archival_location,
workspace_entity.trace_archival_retention,
)
_logger.info("Created workspace '%s'", workspace.name)
return workspace_entity
def update_workspace(self, workspace: Workspace) -> Workspace:
trace_archival_location, trace_archival_retention = (
self._validate_workspace_trace_archival_config(
workspace.trace_archival_location,
workspace.trace_archival_retention,
)
)
with self.ManagedSessionMaker(read_only=False) as session:
entity = self._get_workspace(session, workspace.name)
if workspace.description is not None:
entity.description = workspace.description
if workspace.default_artifact_root is not None:
# If the default_artifact_root is an empty string, set it to None to "clear" the
# value
entity.default_artifact_root = workspace.default_artifact_root or None
if workspace.trace_archival_location is not None:
entity.trace_archival_location = trace_archival_location
if workspace.trace_archival_retention is not None:
entity.trace_archival_retention = trace_archival_retention
session.flush()
_logger.info("Updated workspace '%s'", workspace.name)
workspace_entity = entity.to_mlflow_entity()
# Only update cache after the transaction has successfully committed.
with self._artifact_root_cache_lock:
self._artifact_root_cache[workspace.name] = workspace_entity.default_artifact_root
with self._trace_archival_config_cache_lock:
self._trace_archival_config_cache[workspace.name] = (
workspace_entity.trace_archival_location,
workspace_entity.trace_archival_retention,
)
return workspace_entity
def delete_workspace(
self,
workspace_name: str,
mode: WorkspaceDeletionMode = WorkspaceDeletionMode.RESTRICT,
) -> None:
if workspace_name == DEFAULT_WORKSPACE_NAME:
raise MlflowException(
f"Cannot delete the reserved '{DEFAULT_WORKSPACE_NAME}' workspace",
INVALID_STATE,
)
with self.ManagedSessionMaker(read_only=False) as session:
entity = self._get_workspace(session, workspace_name)
try:
if mode == WorkspaceDeletionMode.RESTRICT:
for model in _WORKSPACE_ROOT_MODELS:
count = (
session.query(model).filter(model.workspace == workspace_name).count()
)
if count:
raise MlflowException(
f"Cannot delete workspace '{workspace_name}': table "
f"'{model.__tablename__}' still contains {count} resource(s). "
"Remove or reassign them before deleting the workspace.",
INVALID_STATE,
)
elif mode == WorkspaceDeletionMode.CASCADE:
for model in _WORKSPACE_ROOT_MODELS:
instances = (
session.query(model).filter(model.workspace == workspace_name).all()
)
for obj in instances:
session.delete(obj)
elif mode == WorkspaceDeletionMode.SET_DEFAULT:
self._check_set_default_conflicts(session, workspace_name)
for model in _WORKSPACE_ROOT_MODELS:
session.query(model).filter(model.workspace == workspace_name).update(
{model.workspace: DEFAULT_WORKSPACE_NAME},
synchronize_session=False,
)
else:
raise MlflowException.invalid_parameter_value(
f"Invalid workspace deletion mode {mode!r}. "
"Expected one of: RESTRICT, CASCADE, SET_DEFAULT."
)
session.delete(entity)
except IntegrityError as exc:
if mode == WorkspaceDeletionMode.SET_DEFAULT:
message = (
f"Cannot delete workspace '{workspace_name}': resources in this workspace "
f"conflict with existing resources in the '{DEFAULT_WORKSPACE_NAME}' "
f"workspace. Resolve naming conflicts before deleting. Error: {exc}"
)
else:
message = (
f"Cannot delete workspace '{workspace_name}': deletion failed due to "
f"database integrity constraints while operating in '{mode.value}' mode. "
"This often indicates that related resources still reference this "
f"workspace. Error: {exc}"
)
raise MlflowException(message, INVALID_STATE) from exc
_logger.info("Deleted workspace '%s' (mode=%s)", workspace_name, mode.value)
if mode == WorkspaceDeletionMode.CASCADE:
_logger.info(
"Run 'mlflow gc --backend-store-uri %s' to permanently clean up "
"artifacts associated with deleted resources.",
self._workspace_uri,
)
with self._artifact_root_cache_lock:
self._artifact_root_cache.pop(workspace_name, None)
with self._trace_archival_config_cache_lock:
self._trace_archival_config_cache.pop(workspace_name, None)
def get_default_workspace(self) -> Workspace:
return self.get_workspace(DEFAULT_WORKSPACE_NAME)
def resolve_artifact_root(
self, default_artifact_root: str | None, workspace_name: str
) -> tuple[str | None, bool]:
with self._artifact_root_cache_lock:
cached_value = self._artifact_root_cache.get(workspace_name, _CACHE_MISS)
if cached_value is not _CACHE_MISS:
if cached_value:
return cached_value, False
return default_artifact_root, True
with self.ManagedSessionMaker() as session:
workspace = session.get(SqlWorkspace, workspace_name)
workspace_root = workspace.default_artifact_root if workspace else None
with self._artifact_root_cache_lock:
self._artifact_root_cache[workspace_name] = workspace_root
if workspace_root:
return workspace_root, False
return default_artifact_root, True
def resolve_trace_archival_config(
self,
default_trace_archival_root: str,
default_retention: str,
workspace_name: str,
) -> ResolvedTraceArchivalConfig:
with self._trace_archival_config_cache_lock:
cached_value = self._trace_archival_config_cache.get(workspace_name, _CACHE_MISS)
if cached_value is _CACHE_MISS:
with self.ManagedSessionMaker() as session:
workspace_row = (
session
.query(
SqlWorkspace.trace_archival_location,
SqlWorkspace.trace_archival_retention,
)
.filter(SqlWorkspace.name == workspace_name)
.one_or_none()
)
if workspace_row is None:
workspace_root = None
workspace_retention = None
else:
workspace_root, workspace_retention = workspace_row
with self._trace_archival_config_cache_lock:
self._trace_archival_config_cache[workspace_name] = (
workspace_root,
workspace_retention,
)
else:
workspace_root, workspace_retention = cached_value
return ResolvedTraceArchivalConfig(
config=TraceArchivalConfig(
location=workspace_root or default_trace_archival_root,
retention=workspace_retention or default_retention,
),
append_workspace_prefix=not bool(workspace_root),
)
@staticmethod
def _check_set_default_conflicts(session, workspace_name: str) -> None:
"""Preflight check: report all name conflicts that would arise from reassigning
resources in *workspace_name* to the default workspace.
"""
conflicts: list[str] = []
for model in _WORKSPACE_ROOT_MODELS:
if not hasattr(model, "name"):
continue
overlapping = (
session
.query(model.name)
.filter(model.workspace == workspace_name)
.filter(
model.name.in_(
session.query(model.name).filter(model.workspace == DEFAULT_WORKSPACE_NAME)
)
)
.all()
)
for (name,) in overlapping:
conflicts.append(f" - {model.__tablename__}: {name!r}")
if conflicts:
details = "\n".join(conflicts)
raise MlflowException(
f"Cannot reassign resources from workspace '{workspace_name}' to "
f"'{DEFAULT_WORKSPACE_NAME}': the following names already exist in the "
f"default workspace and would cause conflicts:\n{details}\n"
"Rename or remove the conflicting resources before retrying.",
INVALID_STATE,
)
def _get_workspace(self, session, workspace_name: str) -> SqlWorkspace:
workspace = session.get(SqlWorkspace, workspace_name)
if workspace is None:
raise MlflowException(
f"Workspace '{workspace_name}' not found",
RESOURCE_DOES_NOT_EXIST,
)
return workspace