-
Notifications
You must be signed in to change notification settings - Fork 451
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 TriblerDatabaseMigrationChain
#7622
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import pytest | ||
|
||
from tribler.core.components.database.db.tribler_database import TriblerDatabase | ||
from tribler.core.upgrade.tribler_db.migration_chain import TriblerDatabaseMigrationChain | ||
from tribler.core.utilities.path_util import Path | ||
from tribler.core.utilities.simpledefs import STATEDIR_DB_DIR | ||
|
||
|
||
# pylint: disable=redefined-outer-name | ||
|
||
|
||
@pytest.fixture | ||
def migration_chain(tmpdir): | ||
""" Create an empty migration chain with an empty database.""" | ||
db_file_name = Path(tmpdir) / STATEDIR_DB_DIR / 'tribler.db' | ||
db_file_name.parent.mkdir() | ||
TriblerDatabase(filename=str(db_file_name)) | ||
return TriblerDatabaseMigrationChain(state_dir=Path(tmpdir), chain=[]) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import functools | ||
import logging | ||
from typing import Callable, Optional | ||
|
||
from tribler.core.components.database.db.tribler_database import TriblerDatabase | ||
from tribler.core.utilities.pony_utils import db_session | ||
|
||
MIGRATION_METADATA = "_tribler_db_migration" | ||
|
||
logger = logging.getLogger('Migration (TriblerDB)') | ||
|
||
|
||
def migration(execute_only_if_version: int, set_after_successful_execution_version: Optional[int] = None): | ||
""" Decorator for migration functions. | ||
The migration executes in the single transaction. If the migration fails, the transaction is rolled back. | ||
The decorator also sets the metadata attribute to the decorated function. It could be checked by | ||
calling the `has_migration_metadata` function. | ||
Args: | ||
execute_only_if_version: Execute the migration only if the current db version is equal to this value. | ||
set_after_successful_execution_version: Set the db version to this value after the migration is executed. | ||
If it is not specified, then `set_after_successful_execution_version = execute_only_if_version + 1` | ||
""" | ||
|
||
def decorator(func): | ||
@functools.wraps(func) | ||
@db_session | ||
def wrapper(db: TriblerDatabase, **kwargs): | ||
target_version = execute_only_if_version | ||
if target_version != db.version: | ||
logger.info( | ||
f"Function {func.__name__} is not executed because DB version is not equal to {target_version}. " | ||
f"The current db version is {db.version}" | ||
) | ||
return None | ||
|
||
result = func(db, **kwargs) | ||
|
||
next_version = set_after_successful_execution_version | ||
if next_version is None: | ||
next_version = target_version + 1 | ||
db.version = next_version | ||
|
||
return result | ||
|
||
setattr(wrapper, MIGRATION_METADATA, {}) | ||
return wrapper | ||
|
||
return decorator | ||
|
||
|
||
def has_migration_metadata(f: Callable): | ||
""" Check if the function has migration metadata.""" | ||
return hasattr(f, MIGRATION_METADATA) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,48 @@ | ||
import logging | ||
from typing import Callable, List, Optional | ||
|
||
from tribler.core.components.database.db.tribler_database import TriblerDatabase | ||
from tribler.core.upgrade.tribler_db.decorator import has_migration_metadata | ||
from tribler.core.upgrade.tribler_db.scheme_migrations.scheme_migration_0 import scheme_migration_0 | ||
from tribler.core.utilities.path_util import Path | ||
from tribler.core.utilities.simpledefs import STATEDIR_DB_DIR | ||
|
||
|
||
class TriblerDatabaseMigrationChain: | ||
""" A chain of migrations that can be executed on a TriblerDatabase. | ||
|
||
To create a new migration, create a new function and decorate it with the `migration` decorator. Then add it to | ||
the `DEFAULT_CHAIN` list. | ||
""" | ||
|
||
DEFAULT_CHAIN = [ | ||
scheme_migration_0, | ||
# add your migration here | ||
] | ||
|
||
def __init__(self, state_dir: Path, chain: Optional[List[Callable]] = None): | ||
self.logger = logging.getLogger(self.__class__.__name__) | ||
self.state_dir = state_dir | ||
|
||
db_path = self.state_dir / STATEDIR_DB_DIR / 'tribler.db' | ||
self.logger.info(f'Tribler DB path: {db_path}') | ||
self.db = TriblerDatabase(str(db_path), check_tables=False) if db_path.is_file() else None | ||
|
||
self.migrations = chain or self.DEFAULT_CHAIN | ||
|
||
def execute(self) -> bool: | ||
""" Execute all migrations in the chain. | ||
|
||
Returns: True if all migrations were executed successfully, False otherwise. | ||
An exception in any of the migrations will halt the execution chain and be re-raised. | ||
""" | ||
|
||
if not self.db: | ||
return False | ||
|
||
for m in self.migrations: | ||
if not has_migration_metadata(m): | ||
raise NotImplementedError(f'The migration {m} should have `migration` decorator') | ||
m(self.db, state_dir=self.state_dir) | ||
|
||
return True |
23 changes: 23 additions & 0 deletions
23
src/tribler/core/upgrade/tribler_db/scheme_migrations/scheme_migration_0.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
from tribler.core.components.database.db.tribler_database import TriblerDatabase | ||
from tribler.core.upgrade.tribler_db.decorator import migration | ||
|
||
|
||
@migration(execute_only_if_version=0) | ||
def scheme_migration_0(db: TriblerDatabase, **kwargs): # pylint: disable=unused-argument | ||
""" "This is initial migration, placed here primarily for demonstration purposes. | ||
It doesn't do anything except set the database version to `1`. | ||
|
||
For upcoming migrations, there are some guidelines: | ||
1. functions should contain a single parameter, `db: TriblerDatabase`, | ||
2. they should apply the `@migration` decorator. | ||
|
||
|
||
Utilizing plain SQL (as seen in the example below) is considered good practice since it helps prevent potential | ||
inconsistencies in DB schemes in the future (model versions preceding the current one may differ from it). | ||
For more information see: https://github.com/Tribler/tribler/issues/7382 | ||
|
||
The example of a migration: | ||
|
||
db.execute('ALTER TABLE "TorrentState" ADD "has_data" BOOLEAN DEFAULT 0') | ||
db.execute('UPDATE "TorrentState" SET "has_data" = 1 WHERE last_check > 0') | ||
""" |
13 changes: 13 additions & 0 deletions
13
src/tribler/core/upgrade/tribler_db/scheme_migrations/tests/test_scheme_migration_0.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
from tribler.core.upgrade.tribler_db.migration_chain import TriblerDatabaseMigrationChain | ||
from tribler.core.upgrade.tribler_db.scheme_migrations.scheme_migration_0 import scheme_migration_0 | ||
from tribler.core.utilities.pony_utils import db_session | ||
|
||
|
||
@db_session | ||
def test_scheme_migration_0(migration_chain: TriblerDatabaseMigrationChain): | ||
""" Test that the scheme_migration_0 changes the database version to 1. """ | ||
migration_chain.db.version = 0 | ||
migration_chain.migrations = [scheme_migration_0] | ||
|
||
assert migration_chain.execute() | ||
assert migration_chain.db.version == 1 |
74 changes: 74 additions & 0 deletions
74
src/tribler/core/upgrade/tribler_db/tests/test_decorator.py
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
from unittest.mock import Mock | ||
|
||
import pytest | ||
|
||
from tribler.core.upgrade.tribler_db.decorator import has_migration_metadata, migration | ||
|
||
|
||
def test_migration_execute_only_if_version(): | ||
""" Test that migration is executed only if the version of the database is equal to the specified one.""" | ||
|
||
@migration(execute_only_if_version=1) | ||
def test(_: Mock): | ||
return True | ||
|
||
assert test(Mock(version=1)) | ||
assert not test(Mock(version=2)) | ||
|
||
|
||
def test_set_after_successful_execution_version(): | ||
""" Test that the version of the database is set to the specified one after the migration is successfully | ||
executed. | ||
""" | ||
|
||
@migration(execute_only_if_version=1, set_after_successful_execution_version=33) | ||
def test(_: Mock): | ||
... | ||
|
||
db = Mock(version=1) | ||
test(db) | ||
|
||
assert db.version == 33 | ||
|
||
|
||
def test_set_after_successful_execution_version_not_specified(): | ||
""" Test that if the version is not specified, the version of the database will be set to | ||
execute_only_if_version + 1 | ||
""" | ||
|
||
@migration(execute_only_if_version=1) | ||
def test(_: Mock): | ||
... | ||
|
||
db = Mock(version=1) | ||
test(db) | ||
|
||
assert db.version == 2 | ||
|
||
|
||
def test_set_after_successful_execution_raise_an_exception(): | ||
""" Test that if an exception is raised during the migration, the version of the database is not changed.""" | ||
|
||
@migration(execute_only_if_version=1, set_after_successful_execution_version=33) | ||
def test(_: Mock): | ||
raise TypeError | ||
|
||
db = Mock(version=1) | ||
with pytest.raises(TypeError): | ||
test(db) | ||
|
||
assert db.version == 1 | ||
|
||
|
||
def test_set_metadata(): | ||
""" Test that the metadata flag is set.""" | ||
|
||
@migration(execute_only_if_version=1) | ||
def simple_migration(_: Mock): | ||
... | ||
|
||
def no_migration(_: Mock): | ||
... | ||
|
||
assert has_migration_metadata(simple_migration) | ||
assert not has_migration_metadata(no_migration) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks dangerous to silently skip the migration in case of a version mismatch and can lead to incorrect migrations.
As a possible example:
[a, b, c]
, and after the migrationc
, the database has version 3d
ande
, both require db version 3[a, b, c, d, e]
, but bothd
ande
still mistakenly require version 3 of the database.e
For that reason, it is better to raise an error instead of skipping the migration so the database will not be left in a surprising state.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we were to raise an exception as you suggested, then almost all runs would result in unsuccessful execution.
Example:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can first select suitable migrations, as described in the previous comment, and execute only them. This way, the migration decorator can throw an exception if the migration was performed at an improper moment. It looks safer than silently skipping migrations with incorrect db version numbers.
Also, consider the migrations [a, b, c, d, e] that mistakenly expect the schema version [1, 3, 2, 4, 5]. With silent skipping of the incorrect migrations, only migrations 1 and 2 will be executed. It is better to prevent such errors.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actually, I'm not sure that it is a good idea to automatically fix potential merge conflict errors.
Also, I'm not sure that the Migration Chain should have a monopoly on changing the version value.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I agree, and what I like in the current approach is that the merge conflicts for migrations always require explicit conflict resolution, as migrations are explicitly listed in the same list.
What I'm concerned about is that the current PR approach allows silent skipping of migration if the schema number specified in the decorator does not agree with the order of migrations in the explicit list. Raising an error looks like a safer approach.