Skip to content

Commit 8e7c1ae

Browse files
Merge pull request #46 from Aiven-Open/daniel.blasina/migration_error_message
migration: saving migration error to a file
2 parents c5a6272 + 60a13db commit 8e7c1ae

File tree

5 files changed

+84
-0
lines changed

5 files changed

+84
-0
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ optional arguments:
7070
Max total size of databases to be migrated, ignored by default
7171
--output-meta-file OUTPUT_META_FILE
7272
Output file which includes metadata such as dump GTIDs (for replication method only) in JSON format.
73+
--output_error_file OUTPUT_ERROR_FILE
74+
Save migration error to file in JSON format.
7375
--allow-source-without-dbs
7476
Allow migrating from a source that has no migratable databases
7577
```

aiven_mysql_migrate/main.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,13 @@ def main(args: Sequence[str] | None = None, *, app: str = "mysql_migrate") -> Op
6262
default=None,
6363
help="Output file which includes metadata such as dump GTIDs (for replication method only) in JSON format.",
6464
)
65+
parser.add_argument(
66+
"--output-error-file",
67+
type=Path,
68+
required=False,
69+
default=None,
70+
help="Save migration error to file in JSON format.",
71+
)
6572
parser.add_argument(
6673
"--allow-source-without-dbs",
6774
action="store_true",
@@ -89,6 +96,7 @@ def main(args: Sequence[str] | None = None, *, app: str = "mysql_migrate") -> Op
8996
privilege_check_user=parsed_args.privilege_check_user,
9097
output_meta_file=parsed_args.output_meta_file,
9198
dump_tool=parsed_args.dump_tool,
99+
output_error_file=parsed_args.output_error_file,
92100
)
93101
migration.setup_signal_handlers()
94102

aiven_mysql_migrate/migration.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Copyright (c) 2020 Aiven, Helsinki, Finland. https://aiven.io/
2+
import datetime
3+
24
from aiven_mysql_migrate import config
35
from aiven_mysql_migrate.dump_tools import MySQLMigrationToolBase, get_dump_tool
46
from aiven_mysql_migrate.enums import MySQLMigrateTool, MySQLMigrateMethod
@@ -8,6 +10,7 @@
810
SSLNotSupportedException, TooManyDatabasesException, UnsupportedBinLogFormatException, UnsupportedMySQLEngineException,
911
UnsupportedMySQLVersionException, WrongMigrationConfigurationException
1012
)
13+
from aiven_mysql_migrate.migration_error import MysqlMigrationError
1114
from aiven_mysql_migrate.utils import MySQLConnectionInfo, PrivilegeCheckUser, select_global_var
1215
from looseversion import LooseVersion
1316
from pathlib import Path
@@ -41,6 +44,7 @@ def __init__(
4144
privilege_check_user: Optional[str] = None,
4245
output_meta_file: Optional[Path] = None,
4346
dump_tool: MySQLMigrateTool = MySQLMigrateTool.mysqldump,
47+
output_error_file: Optional[Path] = None,
4448
):
4549
self.dump_tool_name = dump_tool
4650
self.dump_tool: Optional[MySQLMigrationToolBase] = None
@@ -61,6 +65,7 @@ def __init__(
6165
if privilege_check_user:
6266
self.privilege_check_user = PrivilegeCheckUser.parse(privilege_check_user)
6367
self.output_meta_file = output_meta_file
68+
self.output_error_file = output_error_file
6469

6570
def setup_signal_handlers(self):
6671
signal.signal(signal.SIGINT, self._stop_migration)
@@ -380,6 +385,24 @@ def _wait_for_replication(self, *, seconds_behind_master: int = 0, check_interva
380385

381386
def start(self, *,
382387
migration_method: MySQLMigrateMethod, seconds_behind_master: int, stop_replication: bool = False) -> None:
388+
try:
389+
self.start_migration(migration_method=migration_method,
390+
seconds_behind_master=seconds_behind_master,
391+
stop_replication=stop_replication)
392+
except Exception as e:
393+
if self.output_error_file is not None:
394+
with open(self.output_error_file, "w", encoding='utf-8') as f:
395+
error = MysqlMigrationError(error_type=e.__class__.__module__ + "." + e.__class__.__name__,
396+
error_msg=str(e),
397+
error_date=datetime.datetime.now(datetime.timezone.utc))
398+
f.write(json.dumps(error.__dict__, default=str))
399+
raise
400+
401+
def start_migration(self,
402+
*,
403+
migration_method: MySQLMigrateMethod,
404+
seconds_behind_master: int,
405+
stop_replication: bool = False) -> None:
383406
LOGGER.info("Start migration of the following databases:")
384407
for db in self.databases:
385408
LOGGER.info("\t%s", db)
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import datetime
2+
3+
4+
class MysqlMigrationError:
5+
error_type: str
6+
error_msg: str
7+
error_date: datetime.datetime
8+
9+
def __init__(self, error_type: str, error_msg: str, error_date: str | datetime.datetime):
10+
self.error_type = error_type
11+
self.error_msg = error_msg
12+
if isinstance(error_date, datetime.datetime):
13+
self.error_date = error_date
14+
else:
15+
self.error_date = datetime.datetime.strptime(error_date, "%Y-%m-%d %H:%M:%S.%f%z")

test/unit/test_utils.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Copyright (c) 2025 Aiven, Helsinki, Finland. https://aiven.io/
2+
import datetime
3+
import json
4+
5+
from pymysql import OperationalError
6+
from aiven_mysql_migrate.enums import MySQLMigrateMethod
7+
8+
from aiven_mysql_migrate.migration import MySQLMigration
9+
210
from aiven_mysql_migrate.exceptions import WrongMigrationConfigurationException
11+
from aiven_mysql_migrate.migration_error import MysqlMigrationError
312
from aiven_mysql_migrate.utils import MySQLConnectionInfo, MySQLDumpProcessor, MydumperDumpProcessor
413
from pathlib import Path
514
from pytest import mark, raises
@@ -292,3 +301,30 @@ def test_mydumper_dump_processor_ignores_database_files_from_metadata_database()
292301
result4 = processor.process_line("-- metadata 0")
293302
assert result4 == "-- metadata 0"
294303
assert processor.gtid == _GTID
304+
305+
306+
def test_failed_migration_generates_file():
307+
migration_error_path = Path("/", "tmp", "error.json")
308+
server_uri = "mysql://user:password@invalid:3306"
309+
migration = MySQLMigration(
310+
source_uri=server_uri,
311+
target_uri=server_uri,
312+
target_master_uri=server_uri,
313+
privilege_check_user="root@%",
314+
output_error_file=migration_error_path
315+
)
316+
with raises(OperationalError):
317+
migration.start(migration_method=MySQLMigrateMethod.dump, seconds_behind_master=0)
318+
319+
with open(migration_error_path, encoding='utf-8') as file:
320+
error: MysqlMigrationError = json.loads(file.read(), object_hook=lambda d: MysqlMigrationError(**d))
321+
assert error.error_type == "pymysql.err.OperationalError"
322+
assert error.error_msg == ("(2003, "
323+
"\"Can't connect to MySQL server on 'invalid' "
324+
"([Errno -2] Name or service not known)\")")
325+
assert error.error_date < datetime.datetime.now(datetime.timezone.utc)
326+
327+
328+
def test_migration_error_fails_on_invalid_date():
329+
with raises(ValueError):
330+
MysqlMigrationError(error_type="pymysql.err.OperationalError", error_msg="message", error_date="invalid")

0 commit comments

Comments
 (0)