Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add InvokeAIAppConfig schema migration system #6243

Closed
wants to merge 33 commits into from
Closed
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6ad1948
add InvokeAIAppConfig schema migration system
Apr 19, 2024
36495b7
use packaging.version rather than version-parse
Apr 19, 2024
b612c73
tidy(config): remove unused TYPE_CHECKING block
psychedelicious Apr 23, 2024
e39f035
tidy(config): removed extraneous ABC
psychedelicious Apr 23, 2024
aca9e44
fix(config): use TypeAlias instead of TypeVar
psychedelicious Apr 23, 2024
6f128c8
tidy(config): use dataclass for MigrationEntry
psychedelicious Apr 23, 2024
5d411e4
tidy(config): use a type alias for the migration function
psychedelicious Apr 23, 2024
d12fb7d
fix(config): fix duplicate migration logic
psychedelicious Apr 23, 2024
984dd93
tests(config): add failing test case to for config migrator
psychedelicious Apr 23, 2024
ab9ebef
tests(config): fix typo
psychedelicious Apr 23, 2024
6eaed9a
check for strictly contiguous from_version->to_version ranges
Apr 25, 2024
048306b
Merge branch 'main' into lstein/feat/config-migration
lstein Apr 25, 2024
ab086a7
Merge branch 'main' into lstein/feat/config-migration
lstein Apr 25, 2024
8144a26
updated and reinstated the test_deny_nodes() unit test
Apr 25, 2024
d248775
reinstated failing deny_nodes validation test for Graph
Apr 25, 2024
d852ca7
added test for non-contiguous migration routines
Apr 28, 2024
59deef9
Merge branch 'main' into lstein/feat/config-migration
Apr 28, 2024
36b1434
Merge branch 'main' into lstein/feat/config-migration
lstein Apr 28, 2024
d5aee87
Merge branch 'main' into lstein/feat/config-migration
lstein Apr 30, 2024
a48abfa
make config migrator into an instance; refactor location of get_config()
May 3, 2024
2bba7f3
Merge branch 'main' into lstein/feat/config-migration
lstein May 3, 2024
2dd42d0
check that right no. of migration steps run
May 3, 2024
31f6302
merge
May 3, 2024
fc23b16
add more checking of migration step operations
May 3, 2024
6946a38
feat(config): simplify config migrator logic
psychedelicious May 14, 2024
18b5aaf
tidy(config): add "config" to class names to differentiate from SQLit…
psychedelicious May 14, 2024
4c081d5
tidy(config): add note about circular deps in config_migrate.py
psychedelicious May 14, 2024
d487102
fix(config): fix config _check_for_discontinuities
psychedelicious May 14, 2024
7d8b011
fix(config): restore missing config field assignment in migration
psychedelicious May 14, 2024
964adb8
tests(config): update tests for config migration
psychedelicious May 14, 2024
00ccd73
Merge branch 'main' into lstein/feat/config-migration
psychedelicious May 14, 2024
6e40142
tests(config): test migrations directly, not via `load_and_migrate_co…
psychedelicious May 14, 2024
8b76d11
tests(config): set root to a tmp dir if didn't parse args
psychedelicious May 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
169 changes: 82 additions & 87 deletions invokeai/app/services/config/config_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
from invokeai.backend.model_hash.model_hash import HASHING_ALGORITHMS
from invokeai.frontend.cli.arg_parser import InvokeAIArgs

from .config_migrate import ConfigMigrator

INIT_FILE = Path("invokeai.yaml")
DB_FILE = Path("invokeai.db")
LEGACY_INIT_FILE = Path("invokeai.init")
Expand Down Expand Up @@ -348,75 +350,6 @@ def settings_customise_sources(
return (init_settings,)


def migrate_v3_config_dict(config_dict: dict[str, Any]) -> InvokeAIAppConfig:
"""Migrate a v3 config dictionary to a current config object.

Args:
config_dict: A dictionary of settings from a v3 config file.

Returns:
An instance of `InvokeAIAppConfig` with the migrated settings.

"""
parsed_config_dict: dict[str, Any] = {}
for _category_name, category_dict in config_dict["InvokeAI"].items():
for k, v in category_dict.items():
# `outdir` was renamed to `outputs_dir` in v4
if k == "outdir":
parsed_config_dict["outputs_dir"] = v
# `max_cache_size` was renamed to `ram` some time in v3, but both names were used
if k == "max_cache_size" and "ram" not in category_dict:
parsed_config_dict["ram"] = v
# `max_vram_cache_size` was renamed to `vram` some time in v3, but both names were used
if k == "max_vram_cache_size" and "vram" not in category_dict:
parsed_config_dict["vram"] = v
# autocast was removed in v4.0.1
if k == "precision" and v == "autocast":
parsed_config_dict["precision"] = "auto"
if k == "conf_path":
parsed_config_dict["legacy_models_yaml_path"] = v
if k == "legacy_conf_dir":
# The old default for this was "configs/stable-diffusion" ("configs\stable-diffusion" on Windows).
if v == "configs/stable-diffusion" or v == "configs\\stable-diffusion":
# If if the incoming config has the default value, skip
continue
elif Path(v).name == "stable-diffusion":
# Else if the path ends in "stable-diffusion", we assume the parent is the new correct path.
parsed_config_dict["legacy_conf_dir"] = str(Path(v).parent)
else:
# Else we do not attempt to migrate this setting
parsed_config_dict["legacy_conf_dir"] = v
elif k in InvokeAIAppConfig.model_fields:
# skip unknown fields
parsed_config_dict[k] = v
# When migrating the config file, we should not include currently-set environment variables.
config = DefaultInvokeAIAppConfig.model_validate(parsed_config_dict)

return config


def migrate_v4_0_0_config_dict(config_dict: dict[str, Any]) -> InvokeAIAppConfig:
"""Migrate v4.0.0 config dictionary to a current config object.

Args:
config_dict: A dictionary of settings from a v4.0.0 config file.

Returns:
An instance of `InvokeAIAppConfig` with the migrated settings.
"""
parsed_config_dict: dict[str, Any] = {}
for k, v in config_dict.items():
# autocast was removed from precision in v4.0.1
if k == "precision" and v == "autocast":
parsed_config_dict["precision"] = "auto"
else:
parsed_config_dict[k] = v
if k == "schema_version":
parsed_config_dict[k] = CONFIG_SCHEMA_VERSION
config = DefaultInvokeAIAppConfig.model_validate(parsed_config_dict)
return config


def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig:
"""Load and migrate a config file to the latest version.

Expand All @@ -432,29 +365,20 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig:

assert isinstance(loaded_config_dict, dict)

if "InvokeAI" in loaded_config_dict:
# This is a v3 config file, attempt to migrate it
shutil.copy(config_path, config_path.with_suffix(".yaml.bak"))
try:
# loaded_config_dict could be the wrong shape, but we will catch all exceptions below
migrated_config = migrate_v3_config_dict(loaded_config_dict) # pyright: ignore [reportUnknownArgumentType]
except Exception as e:
shutil.copy(config_path.with_suffix(".yaml.bak"), config_path)
raise RuntimeError(f"Failed to load and migrate v3 config file {config_path}: {e}") from e
migrated_config.write_file(config_path)
return migrated_config

if loaded_config_dict["schema_version"] == "4.0.0":
loaded_config_dict = migrate_v4_0_0_config_dict(loaded_config_dict)
loaded_config_dict.write_file(config_path)
shutil.copy(config_path, config_path.with_suffix(".yaml.bak"))
try:
# loaded_config_dict could be the wrong shape, but we will catch all exceptions below
migrated_config_dict = ConfigMigrator.migrate(loaded_config_dict) # pyright: ignore [reportUnknownArgumentType]
except Exception as e:
shutil.copy(config_path.with_suffix(".yaml.bak"), config_path)
raise RuntimeError(f"Failed to load and migrate config file {config_path}: {e}") from e

# Attempt to load as a v4 config file
try:
# Meta is not included in the model fields, so we need to validate it separately
config = InvokeAIAppConfig.model_validate(loaded_config_dict)
config = InvokeAIAppConfig.model_validate(migrated_config_dict)
assert (
config.schema_version == CONFIG_SCHEMA_VERSION
), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION}: {config.schema_version}"
), f"Invalid schema version, expected {CONFIG_SCHEMA_VERSION} but got {config.schema_version}"
return config
except Exception as e:
raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e
Expand Down Expand Up @@ -504,6 +428,7 @@ def get_config() -> InvokeAIAppConfig:

if config.config_file_path.exists():
config_from_file = load_and_migrate_config(config.config_file_path)
config_from_file.write_file(config.config_file_path)
# Clobbering here will overwrite any settings that were set via environment variables
config.update_config(config_from_file, clobber=False)
else:
Expand All @@ -512,3 +437,73 @@ def get_config() -> InvokeAIAppConfig:
default_config.write_file(config.config_file_path, as_example=False)

return config


####################################################
# VERSION MIGRATIONS
####################################################


@ConfigMigrator.register(from_version="3.0.0", to_version="4.0.0")
def migrate_1(config_dict: dict[str, Any]) -> dict[str, Any]:
"""Migrate a v3 config dictionary to a current config object.

Args:
config_dict: A dictionary of settings from a v3 config file.

Returns:
A dictionary of settings from a 4.0.0 config file.

"""
parsed_config_dict: dict[str, Any] = {}
for _category_name, category_dict in config_dict["InvokeAI"].items():
for k, v in category_dict.items():
# `outdir` was renamed to `outputs_dir` in v4
if k == "outdir":
parsed_config_dict["outputs_dir"] = v
# `max_cache_size` was renamed to `ram` some time in v3, but both names were used
if k == "max_cache_size" and "ram" not in category_dict:
parsed_config_dict["ram"] = v
# `max_vram_cache_size` was renamed to `vram` some time in v3, but both names were used
if k == "max_vram_cache_size" and "vram" not in category_dict:
parsed_config_dict["vram"] = v
# autocast was removed in v4.0.1
if k == "precision" and v == "autocast":
parsed_config_dict["precision"] = "auto"
if k == "conf_path":
parsed_config_dict["legacy_models_yaml_path"] = v
if k == "legacy_conf_dir":
# The old default for this was "configs/stable-diffusion" ("configs\stable-diffusion" on Windows).
if v == "configs/stable-diffusion" or v == "configs\\stable-diffusion":
# If if the incoming config has the default value, skip
continue
elif Path(v).name == "stable-diffusion":
# Else if the path ends in "stable-diffusion", we assume the parent is the new correct path.
parsed_config_dict["legacy_conf_dir"] = str(Path(v).parent)
else:
# Else we do not attempt to migrate this setting
parsed_config_dict["legacy_conf_dir"] = v
elif k in InvokeAIAppConfig.model_fields:
# skip unknown fields
parsed_config_dict[k] = v
return parsed_config_dict


@ConfigMigrator.register(from_version="4.0.0", to_version="4.0.1")
def migrate_2(config_dict: dict[str, Any]) -> dict[str, Any]:
"""Migrate v4.0.0 config dictionary to v4.0.1.

Args:
config_dict: A dictionary of settings from a v4.0.0 config file.

Returns:
A dictionary of settings from a v4.0.1 config file
"""
parsed_config_dict: dict[str, Any] = {}
for k, v in config_dict.items():
# autocast was removed from precision in v4.0.1
if k == "precision" and v == "autocast":
parsed_config_dict["precision"] = "auto"
else:
parsed_config_dict[k] = v
return parsed_config_dict
89 changes: 89 additions & 0 deletions invokeai/app/services/config/config_migrate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# Copyright 2024 Lincoln D. Stein and the InvokeAI Development Team

"""
Utility class for migrating among versions of the InvokeAI app config schema.
"""

from dataclasses import dataclass
from typing import Any, Callable, List, TypeAlias

from packaging.version import Version

AppConfigDict: TypeAlias = dict[str, Any]
MigrationFunction: TypeAlias = Callable[[AppConfigDict], AppConfigDict]


@dataclass
class MigrationEntry:
"""Defines an individual migration."""

from_version: Version
to_version: Version
function: MigrationFunction


class ConfigMigrator:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because this class is a stateful singleton, testing is difficult. Right now, during tests, this class has all migrations registered in the "live" app. Test failures could possibly pop up over time as more migrations are registered.

Making this an instanced class would mitigate those issues.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. ConfigMigrator is now an instanced class, and test_config.py creates new instances of the migrator for testing decorator functionality, and continuity checking.

The migration steps themselves have been moved into their own module now: invokeai.app.services.config.migrations.

I also relocated get_config() into invokeai.app.services.config.config_migrate, and changed all places that are importing config_default to import from invokeai.app.services.config instead (get_config() is reexported from __init__.py) . This avoided circular import problems and makes config_default.py cleaner looking overall.

"""This class allows migrators to register their input and output versions."""

_migrations: List[MigrationEntry] = []

@classmethod
def register(
cls,
from_version: str,
to_version: str,
) -> Callable[[MigrationFunction], MigrationFunction]:
"""Define a decorator which registers the migration between two versions."""

def decorator(function: MigrationFunction) -> MigrationFunction:
if any(from_version == m.from_version for m in cls._migrations):
raise ValueError(
f"function {function.__name__} is trying to register a migration for version {str(from_version)}, but this migration has already been registered."
)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the added strictness for contiguous migrations, this should probably raise when either from_version or to_version already have a registered migration.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

cls._migrations.append(
MigrationEntry(from_version=Version(from_version), to_version=Version(to_version), function=function)
)
return function

return decorator

@staticmethod
def _check_for_discontinuities(migrations: List[MigrationEntry]) -> None:
current_version = Version("3.0.0")
for m in migrations:
if current_version != m.from_version:
raise ValueError(
f"Migration functions are not continuous. Expected from_version={current_version} but got from_version={m.from_version}, for migration function {m.function.__name__}"
)
current_version = m.to_version

@classmethod
def migrate(cls, config_dict: AppConfigDict) -> AppConfigDict:
"""
Use the registered migration steps to bring config up to latest version.

:param config: The original configuration.
:return: The new configuration, lifted up to the latest version.

As a side effect, the new configuration will be written to disk.
If an inconsistency in the registered migration steps' `from_version`
and `to_version` parameters are identified, this will raise a
ValueError exception.
"""
# Sort migrations by version number and raise a ValueError if
# any version range overlaps are detected.
sorted_migrations = sorted(cls._migrations, key=lambda x: x.from_version)
cls._check_for_discontinuities(sorted_migrations)

if "InvokeAI" in config_dict:
version = Version("3.0.0")
else:
version = Version(config_dict["schema_version"])

for migration in sorted_migrations:
if version == migration.from_version and version < migration.to_version:
config_dict = migration.function(config_dict)
version = migration.to_version

config_dict["schema_version"] = str(version)
return config_dict