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

288 lines
9.4 KiB
Python

import click
@click.group("db")
def commands():
"""
Commands for managing an MLflow tracking database.
"""
@commands.command()
@click.argument("url")
def upgrade(url):
"""
Upgrade the schema of an MLflow tracking database to the latest supported version.
**IMPORTANT**: Schema migrations can be slow and are not guaranteed to be transactional -
**always take a backup of your database before running migrations**. The migrations README,
which is located at
https://github.com/mlflow/mlflow/blob/master/mlflow/store/db_migrations/README.md, describes
large migrations and includes information about how to estimate their performance and
recover from failures.
"""
import mlflow.store.db.utils
engine = mlflow.store.db.utils.create_sqlalchemy_engine_with_retry(url)
if mlflow.store.db.utils._is_empty_database(engine):
mlflow.store.db.utils._initialize_tables(engine)
else:
mlflow.store.db.utils._upgrade_db(engine)
@commands.command("migrate-to-default-workspace")
@click.argument("url")
@click.option(
"--dry-run/--no-dry-run",
default=False,
show_default=True,
help="Check for conflicts and report how many rows would be moved.",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
default=False,
help="List all conflicts instead of truncating the output.",
)
@click.option(
"--yes",
"-y",
is_flag=True,
default=False,
help="Skip the confirmation prompt.",
)
def migrate_to_default_workspace(url, dry_run, verbose, yes):
"""
Move workspace-scoped resources into the default workspace.
**IMPORTANT**: This operation runs in a single transaction, but can still be long-running.
Always take a backup of your database before running this command.
"""
import sqlalchemy.exc
import mlflow.store.db.utils
from mlflow.store.db.workspace_migration import migrate_to_default_workspace as migrate
engine = None
try:
engine = mlflow.store.db.utils.create_sqlalchemy_engine_with_retry(url)
counts = migrate(engine, dry_run=True, verbose=verbose)
total = sum(counts.values())
if dry_run:
click.echo("Dry run completed. Rows that would be moved to the default workspace:")
for table_name, count in counts.items():
click.echo(f" {table_name}: {count}")
click.echo(f"Total rows: {total}")
return
if total == 0:
click.echo("No rows need to be moved.")
return
click.echo("Rows to be moved to the default workspace:")
for table_name, count in counts.items():
click.echo(f" {table_name}: {count}")
click.echo(f"Total rows: {total}")
if not yes:
click.confirm("Proceed with migration?", default=False, abort=True)
migrate(engine, dry_run=False, verbose=verbose)
click.echo(f"Moved {total} rows to the default workspace.")
except RuntimeError as e:
raise click.ClickException(str(e)) from e
except sqlalchemy.exc.SQLAlchemyError as e:
raise click.ClickException(f"Database error: {e}") from e
finally:
if engine is not None:
engine.dispose()
def _parse_tag(value: str) -> tuple[str, str]:
if "=" not in value:
raise click.BadParameter(
f"Tag {value!r} must be in key=value format (e.g. --tag team=team-a)."
)
key, _, val = value.partition("=")
if not key:
raise click.BadParameter(f"Tag {value!r} has an empty key. Use key=value format.")
return key, val
@commands.command("move-resources")
@click.argument("url")
@click.option(
"--from",
"source_workspace",
required=True,
help="Source workspace name.",
)
@click.option(
"--to",
"target_workspace",
required=True,
help="Target workspace name.",
)
@click.option(
"--resource-type",
required=True,
help="Table name of the resource type to move (e.g. experiments, registered_models).",
)
@click.option(
"--name",
multiple=True,
help="Resource name(s) to move. Repeatable.",
)
@click.option(
"--tag",
multiple=True,
help=(
"Tag filter as key=value. Repeatable. "
"When multiple tags are given, only resources matching ALL tags are included."
),
)
@click.option(
"--dry-run/--no-dry-run",
default=False,
show_default=True,
help="Show what would be moved without making changes.",
)
@click.option(
"--verbose",
"-v",
is_flag=True,
default=False,
help="List all conflicts instead of truncating the output.",
)
@click.option(
"--yes",
"-y",
is_flag=True,
default=False,
help="Skip the confirmation prompt.",
)
def move_resources(
url, source_workspace, target_workspace, resource_type, name, tag, dry_run, verbose, yes
):
"""
Move resources from one workspace to another.
Selectively move workspace-scoped resources between workspaces by name
or tag filter (mutually exclusive). When neither --name nor --tag is
specified, all resources of the given type in the source workspace are moved.
The --resource-type value is the database table name (e.g. experiments,
registered_models, evaluation_datasets, webhooks, jobs).
Tag filtering (--tag) is supported for experiments and registered_models
only. When multiple --tag flags are given, only resources matching ALL tags
are included (AND logic).
\b
Examples:
# Move specific experiments by name
mlflow db move-resources sqlite:///mlflow.db \\
--from default --to team-a --resource-type experiments \\
--name training-v1 --name training-v2
# Move experiments matching ALL specified tags
mlflow db move-resources sqlite:///mlflow.db \\
--from default --to team-a --resource-type experiments \\
--tag team=team-a --tag env=prod
# Move all registered models from one workspace to another
mlflow db move-resources sqlite:///mlflow.db \\
--from default --to team-a --resource-type registered_models
**IMPORTANT**: Always take a backup of your database before running this command.
"""
import sqlalchemy.exc
import mlflow.store.db.utils
from mlflow.store.db.workspace_move import RESOURCE_TYPE_CHOICES
from mlflow.store.db.workspace_move import move_resources as move
from mlflow.store.db.workspace_utils import format_truncated_list
if resource_type not in RESOURCE_TYPE_CHOICES:
raise click.ClickException(
f"Unknown resource type {resource_type!r}. "
f"Valid types: {', '.join(RESOURCE_TYPE_CHOICES)}"
)
parsed_tags = [_parse_tag(t) for t in tag] if tag else None
parsed_names = list(name) if name else None
engine = None
try:
engine = mlflow.store.db.utils.create_sqlalchemy_engine_with_retry(url)
needs_confirmation = not dry_run and not yes
result = move(
engine,
source_workspace=source_workspace,
target_workspace=target_workspace,
resource_type=resource_type,
names=parsed_names,
tags=parsed_tags,
dry_run=dry_run or needs_confirmation,
verbose=verbose,
)
if not result.names:
click.echo(f"No {resource_type} to move.")
return
max_display = None if verbose else 20
name_list = format_truncated_list(result.names, max_rows=max_display)
extra_notes: list[str] = []
if result.row_count > len(result.names):
extra_notes.append(
f"Note: {result.row_count} rows match {len(result.names)} distinct "
f"name(s). All rows with a matching name will be moved."
)
if dry_run:
click.echo(
f"Dry run completed. {result.row_count} {resource_type} row(s) would be moved "
f"from {source_workspace!r} to {target_workspace!r}:{name_list}"
)
for note in extra_notes:
click.echo(note)
return
if needs_confirmation:
click.echo(
f"{result.row_count} {resource_type} row(s) to move from "
f"{source_workspace!r} to {target_workspace!r}:{name_list}"
)
for note in extra_notes:
click.echo(note)
click.confirm("Proceed with move?", default=False, abort=True)
# Re-run the full move (including conflict detection) in a new
# transaction. The preview counts above may differ from the
# actual move if another admin modified the data in between,
# but the second call is self-consistent and safe.
result = move(
engine,
source_workspace=source_workspace,
target_workspace=target_workspace,
resource_type=resource_type,
names=parsed_names,
tags=parsed_tags,
dry_run=False,
verbose=verbose,
)
click.echo(
f"Moved {result.row_count} {resource_type} row(s) "
f"from {source_workspace!r} to {target_workspace!r}."
)
except RuntimeError as e:
raise click.ClickException(str(e)) from e
except sqlalchemy.exc.SQLAlchemyError as e:
raise click.ClickException(f"Database error: {e}") from e
finally:
if engine is not None:
engine.dispose()