From 8bee6517c79cbd02d6cc4c9032b24caae794d220 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alfaiate?= Date: Mon, 5 Oct 2026 17:05:08 +0700 Subject: [PATCH] Fix MariaDB connection setup and binlog metadata --- .../core/dbms/DbMySQLSessionSetupTasks.py | 8 +- gui/backend/gui_plugin/core/dbms/DbSession.py | 2 +- .../migration/lib/backend/replication.py | 11 +- .../gui_plugin/migration/lib/dbsession.py | 2 +- .../migration/tests/unit/test_dbsession.py | 67 +++++++ .../migration/tests/unit/test_replication.py | 189 ++++++++++++++++++ .../dbms/test_DbMySQLSessionSetupTasks.py | 73 ++++++- .../tests/unit/core/dbms/test_DbSession.py | 25 ++- 8 files changed, 370 insertions(+), 7 deletions(-) create mode 100644 gui/backend/gui_plugin/migration/tests/unit/test_dbsession.py create mode 100644 gui/backend/gui_plugin/migration/tests/unit/test_replication.py diff --git a/gui/backend/gui_plugin/core/dbms/DbMySQLSessionSetupTasks.py b/gui/backend/gui_plugin/core/dbms/DbMySQLSessionSetupTasks.py index d0265de2..a11fc365 100644 --- a/gui/backend/gui_plugin/core/dbms/DbMySQLSessionSetupTasks.py +++ b/gui/backend/gui_plugin/core/dbms/DbMySQLSessionSetupTasks.py @@ -22,6 +22,7 @@ # 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA from asyncio import current_task +import mysqlsh import gui_plugin.core.dbms.DbMySQLSessionCommon as common from gui_plugin.core.dbms.DbSessionSetupTask import DbSessionSetupTask from gui_plugin.core.dbms.DbSessionUtils import DbSessionData @@ -57,7 +58,12 @@ def on_connected(self): if int(major) < 8 or int(major) == 8 and int(minor) < 3: return - self.execute("SET SESSION explain_json_format_version=2") + try: + self.execute("SET SESSION explain_json_format_version=2") + except mysqlsh.DBError as e: + # Skip unsupported setting (error 1193 = unknown system variable). + if e.code != 1193: + raise class HeatWaveCheckTask(DbSessionSetupTask): diff --git a/gui/backend/gui_plugin/core/dbms/DbSession.py b/gui/backend/gui_plugin/core/dbms/DbSession.py index be090f4e..1ce86cff 100644 --- a/gui/backend/gui_plugin/core/dbms/DbSession.py +++ b/gui/backend/gui_plugin/core/dbms/DbSession.py @@ -251,7 +251,7 @@ def terminate_thread(self): self._close_database(True) if self.thread_error is not None: logger.error(f"Thread {self._id} exiting with code {self.thread_error}") - self._message_callback("ERROR", self.thread_error) + self._message_callback("ERROR", self.thread_error, None) self._term_complete.set() def execute_thread(self, sql, params, options=None): diff --git a/gui/backend/gui_plugin/migration/lib/backend/replication.py b/gui/backend/gui_plugin/migration/lib/backend/replication.py index eaa58784..02f8a503 100644 --- a/gui/backend/gui_plugin/migration/lib/backend/replication.py +++ b/gui/backend/gui_plugin/migration/lib/backend/replication.py @@ -37,7 +37,7 @@ def get_binlog_info( gtid_mode = None if log_bin: - if session.nversion >= 56000: + if session.nversion >= 50605 and session.server_type != ServerType.MariaDB: gtid_mode = session.run_sql("select @@gtid_mode").fetch_one()[0] if session.server_type in [ServerType.RDS, ServerType.Aurora]: @@ -46,6 +46,15 @@ def get_binlog_info( for row in iter(res.fetch_one, None): if row[0] == "binlog retention hours": expiration = int(row[1] or 0) * 3600 + elif session.server_type == ServerType.MariaDB: + # Zero retention disables automatic binlog purging. + if session.nversion >= 100601: + row = session.run_sql("select @@binlog_expire_logs_seconds").fetch_one() + expiration = int(row[0]) or None + else: + row = session.run_sql("select @@expire_logs_days").fetch_one() + days = float(row[0]) + expiration = int(days * 86400) if days else None elif session.nversion >= 80000: row = session.run_sql( "select @@binlog_expire_logs_seconds /*!80029 , @@binlog_expire_logs_auto_purge */" diff --git a/gui/backend/gui_plugin/migration/lib/dbsession.py b/gui/backend/gui_plugin/migration/lib/dbsession.py index c2104245..c7d59f5c 100644 --- a/gui/backend/gui_plugin/migration/lib/dbsession.py +++ b/gui/backend/gui_plugin/migration/lib/dbsession.py @@ -146,7 +146,7 @@ def full_version(self): def detect_server_type(session: MigrationSession) -> ServerType: # Checked with mariadb 10.3.39 - if "MariaDB" in session.version_comment: + if "MariaDB" in session.version or "mariadb" in session.version_comment.lower(): return ServerType.MariaDB # Checked with percona 5.7.44 if "Percona" in session.version_comment: diff --git a/gui/backend/gui_plugin/migration/tests/unit/test_dbsession.py b/gui/backend/gui_plugin/migration/tests/unit/test_dbsession.py new file mode 100644 index 00000000..ac45e7c3 --- /dev/null +++ b/gui/backend/gui_plugin/migration/tests/unit/test_dbsession.py @@ -0,0 +1,67 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, +# as published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, as +# designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an additional +# permission to link the program and your derivative works with the +# separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from gui_plugin.migration.lib.backend.model import ServerType +from gui_plugin.migration.lib.dbsession import detect_server_type + + +@pytest.mark.parametrize( + "version, comment", + [ + ("10.11.19-MariaDB-ubu2404", "Ubuntu 24.04"), + ("11.4.8-MariaDB", "mariadb.org binary distribution"), + ("5.5.5-10.11.19-MariaDB", "Source distribution"), + ("10.3.39", "MariaDB Server"), + ("10.11.19", "mariadb.org binary distribution"), + ("11.4.8", "MARIADB SERVER"), + ], +) +def test_detect_mariadb(version, comment): + session = SimpleNamespace(version=version, version_comment=comment, run_sql=Mock()) + + assert detect_server_type(session) == ServerType.MariaDB + session.run_sql.assert_not_called() + + +@pytest.mark.parametrize( + "comment, basedir, expected", + [ + ("MySQL Community Server - GPL", "/usr/", ServerType.MySQL), + ("Percona Server (GPL)", "/usr/", ServerType.Percona), + ("MySQL Enterprise - Cloud", "/usr/", ServerType.HeatWave), + ("Source distribution", "/rdsdbbin/mysql/", ServerType.RDS), + ("Source distribution", "/rdsdbbin/oscar/", ServerType.Aurora), + ], +) +def test_detect_mysql_variants(comment, basedir, expected): + session = SimpleNamespace( + version="8.4.6-cloud", version_comment=comment, run_sql=Mock() + ) + session.run_sql.return_value.fetch_one.return_value = (basedir,) + + assert detect_server_type(session) == expected diff --git a/gui/backend/gui_plugin/migration/tests/unit/test_replication.py b/gui/backend/gui_plugin/migration/tests/unit/test_replication.py new file mode 100644 index 00000000..9b31d7b9 --- /dev/null +++ b/gui/backend/gui_plugin/migration/tests/unit/test_replication.py @@ -0,0 +1,189 @@ +# Copyright (c) 2026, Oracle and/or its affiliates. +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2.0, +# as published by the Free Software Foundation. +# +# This program is designed to work with certain software (including +# but not limited to OpenSSL) that is licensed under separate terms, as +# designated in a particular file or component or in included license +# documentation. The authors of MySQL hereby grant you an additional +# permission to link the program and your derivative works with the +# separately licensed software that they have either included with +# the program or referenced in the documentation. +# +# This program is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU General Public License, version 2.0, for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA + +from decimal import Decimal +from types import SimpleNamespace +from unittest.mock import Mock, call + +import mysqlsh +import pytest + +from gui_plugin.migration.lib.backend.checks import check_inbound_replication_requirements +from gui_plugin.migration.lib.backend.model import MessageLevel, ServerType +from gui_plugin.migration.lib.backend.replication import get_binlog_info + + +def mock_result(*rows): + result = Mock() + result.fetch_one.side_effect = [*rows, None] + return result + + +@pytest.mark.parametrize("nversion", [100339, 100511, 100600]) +@pytest.mark.parametrize( + "days, expiration", + [(Decimal("0"), None), (Decimal("1.5"), 129600), (Decimal("7"), 604800)], +) +def test_older_mariadb_binlog_info(nversion, days, expiration): + session = SimpleNamespace( + nversion=nversion, server_type=ServerType.MariaDB, run_sql=Mock() + ) + session.run_sql.side_effect = [ + mock_result((1, "ROW")), + mock_result((days,)), + ] + + assert get_binlog_info(session) == ("ROW", None, expiration) + assert session.run_sql.call_args_list == [ + call("select @@log_bin, @@binlog_format"), + call("select @@expire_logs_days"), + ] + + +@pytest.mark.parametrize("nversion", [100601, 101119, 110408]) +@pytest.mark.parametrize("seconds, expiration", [(0, None), (1800, 1800), (3600, 3600)]) +def test_mariadb_binlog_info(nversion, seconds, expiration): + session = SimpleNamespace( + nversion=nversion, server_type=ServerType.MariaDB, run_sql=Mock() + ) + session.run_sql.side_effect = [ + mock_result((1, "ROW")), + mock_result((seconds,)), + ] + + assert get_binlog_info(session) == ("ROW", None, expiration) + assert session.run_sql.call_args_list == [ + call("select @@log_bin, @@binlog_format"), + call("select @@binlog_expire_logs_seconds"), + ] + + +@pytest.mark.parametrize( + "nversion, retention, expected_level", + [ + (100339, Decimal("0"), None), + (100600, Decimal("0"), None), + (100601, 0, None), + (101119, 0, None), + (110408, 0, None), + (100339, Decimal("0.000001"), MessageLevel.ERROR), + (100339, Decimal("0.5"), MessageLevel.WARNING), + (100339, Decimal("1"), None), + (101119, 900, MessageLevel.ERROR), + (101119, 3600, MessageLevel.WARNING), + (101119, 86400, None), + ], +) +def test_mariadb_retention_replication_check(nversion, retention, expected_level): + session = SimpleNamespace( + nversion=nversion, server_type=ServerType.MariaDB, run_sql=Mock() + ) + session.run_sql.side_effect = [ + mock_result((1, "ROW")), + mock_result((retention,)), + ] + + error = check_inbound_replication_requirements( + session, SimpleNamespace(sslSupported=True) + ) + + if expected_level is None: + assert error is None + else: + assert error is not None + assert error.level == expected_level + + +@pytest.mark.parametrize("server_type", [ServerType.MySQL, ServerType.MariaDB]) +def test_disabled_binlog(server_type): + session = SimpleNamespace(nversion=101119, server_type=server_type, run_sql=Mock()) + session.run_sql.return_value = mock_result((0, "ROW")) + + assert get_binlog_info(session) == (None, None, None) + session.run_sql.assert_called_once_with("select @@log_bin, @@binlog_format") + + +@pytest.mark.parametrize( + "nversion, gtid_mode, expire_row, expiration", + [ + (50562, None, None, None), + (50600, None, None, None), + (50604, None, None, None), + (50605, "OFF", None, None), + (50744, "ON", None, None), + (80000, "ON", (3600,), 3600), + (80029, "OFF", (3600, 0), None), + (80406, "ON", (3600, 1), 3600), + (260700, "ON", (3600, 1), 3600), + ], +) +def test_mysql_binlog_info(nversion, gtid_mode, expire_row, expiration): + session = SimpleNamespace( + nversion=nversion, server_type=ServerType.MySQL, run_sql=Mock() + ) + queries = [call("select @@log_bin, @@binlog_format")] + results = [mock_result((1, "ROW"))] + if gtid_mode is not None: + queries.append(call("select @@gtid_mode")) + results.append(mock_result((gtid_mode,))) + if expire_row is not None: + queries.append( + call( + "select @@binlog_expire_logs_seconds /*!80029 , @@binlog_expire_logs_auto_purge */" + ) + ) + results.append(mock_result(expire_row)) + session.run_sql.side_effect = results + + assert get_binlog_info(session) == ("ROW", gtid_mode, expiration) + assert session.run_sql.call_args_list == queries + + +@pytest.mark.parametrize("server_type", [ServerType.RDS, ServerType.Aurora]) +def test_rds_binlog_retention(server_type): + session = SimpleNamespace(nversion=80406, server_type=server_type, run_sql=Mock()) + session.run_sql.side_effect = [ + mock_result((1, "ROW")), + mock_result(("ON",)), + mock_result(("other setting", 0), ("binlog retention hours", 24)), + ] + + assert get_binlog_info(session) == ("ROW", "ON", 86400) + assert session.run_sql.call_args_list == [ + call("select @@log_bin, @@binlog_format"), + call("select @@gtid_mode"), + call("CALL mysql.rds_show_configuration"), + ] + + +@pytest.mark.parametrize("code", [1193, 2013]) +def test_mysql_gtid_error_is_not_ignored(code): + session = SimpleNamespace( + nversion=80406, server_type=ServerType.MySQL, run_sql=Mock() + ) + error = mysqlsh.DBError(code, "GTID query failed", "HY000") + session.run_sql.side_effect = [mock_result((1, "ROW")), error] + + with pytest.raises(mysqlsh.DBError) as exp: + get_binlog_info(session) + assert exp.value is error diff --git a/gui/backend/tests/unit/core/dbms/test_DbMySQLSessionSetupTasks.py b/gui/backend/tests/unit/core/dbms/test_DbMySQLSessionSetupTasks.py index 67be1c77..21176ef4 100644 --- a/gui/backend/tests/unit/core/dbms/test_DbMySQLSessionSetupTasks.py +++ b/gui/backend/tests/unit/core/dbms/test_DbMySQLSessionSetupTasks.py @@ -21,6 +21,7 @@ # along with this program; if not, write to the Free Software Foundation, Inc., # 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA +import mysqlsh import pytest import gui_plugin.core.dbms.DbMySQLSessionSetupTasks as Tasks import gui_plugin.core.dbms.DbMySQLSession as DbMySQLSession @@ -128,6 +129,8 @@ def execute_thread(self, sql, params, options=None): eparams == params ), f"Unexpected SQL Parameters Received, expected {eparams}, received {params}" self._next_query += 1 + if isinstance(result, Exception): + raise result return result @@ -173,10 +176,11 @@ def test_connection(self): class TestExplainJsonFormatVersionTask: - def test_connection_supported_version(self): + @pytest.mark.parametrize("version", ["8.3.0", "8.4.6", "9.0.0", "26.7.0"]) + def test_connection_supported_version(self, version): options = {"scheme": "mysql"} on_connect_options = options.copy() - data = {common.MySQLData.VERSION_INFO: "8.3.0"} + data = {common.MySQLData.VERSION_INFO: version} session = MockDbSession( Tasks.ExplainJsonFormatVersionTask, True, @@ -190,6 +194,71 @@ def test_connection_supported_version(self): ) session.reconnect() + @pytest.mark.parametrize("version", [None, "5.7.44", "8.0.30", "8.2.0"]) + def test_connection_unsupported_version(self, version): + options = {"scheme": "mysql"} + data = {} if version is None else {common.MySQLData.VERSION_INFO: version} + session = MockDbSession( + Tasks.ExplainJsonFormatVersionTask, + True, + options, + options.copy(), + data, + known_data=data, + ) + session.reconnect() + + @pytest.mark.parametrize("version", ["10.11.19-MariaDB-ubu2404", "11.4.8-MariaDB"]) + def test_connection_without_explain_json_format_version(self, version): + options = {"scheme": "mysql"} + data = {common.MySQLData.VERSION_INFO: version} + session = MockDbSession( + Tasks.ExplainJsonFormatVersionTask, + True, + options, + options.copy(), + data, + known_data=data, + expected_queries=[ + ( + ("SET SESSION explain_json_format_version=2", None), + mysqlsh.DBError( + 1193, + "Unknown system variable 'explain_json_format_version'", + "HY000", + ), + ) + ], + ) + session.reconnect() + + @pytest.mark.parametrize( + "error", + [ + mysqlsh.DBError( + 2013, "Lost connection to MySQL server during query", "HY000" + ), + mysqlsh.DBError(1227, "Access denied", "42000"), + RuntimeError("Unexpected setup failure"), + ], + ) + def test_connection_error_is_not_ignored(self, error): + options = {"scheme": "mysql"} + data = {common.MySQLData.VERSION_INFO: "8.4.6"} + with pytest.raises(type(error)) as exp: + MockDbSession( + Tasks.ExplainJsonFormatVersionTask, + True, + options, + options.copy(), + data, + known_data=data, + expected_queries=[ + (("SET SESSION explain_json_format_version=2", None), error) + ], + ) + assert exp.value is error + class TestHeatWaveCheckTask: def test_connection_hw_check_already_known(self): diff --git a/gui/backend/tests/unit/core/dbms/test_DbSession.py b/gui/backend/tests/unit/core/dbms/test_DbSession.py index 2197a742..588d966e 100644 --- a/gui/backend/tests/unit/core/dbms/test_DbSession.py +++ b/gui/backend/tests/unit/core/dbms/test_DbSession.py @@ -36,7 +36,7 @@ import gui_plugin.core.Logger as logger from gui_plugin.core.Error import MSGException from gui_plugin.core.dbms.DbMySQLSession import DbMysqlSession -from gui_plugin.core.dbms.DbSession import ReconnectionMode +from gui_plugin.core.dbms.DbSession import DbSession, ReconnectionMode from gui_plugin.core.dbms.DbSqliteSession import DbSqliteSession from gui_plugin.db_connections import DbConnections from tests.lib.utils import backend_callback, backend_callback_with_pending @@ -156,6 +156,29 @@ def mysql_sessions(mysql_connections_exists): # pragma: no cover session._term_complete.wait() +class TestDbSession: + def test_terminate_thread_reports_original_error(self, monkeypatch): + session = DbSession("test-session", True, {}) + session.thread_error = mysqlsh.DBError( + 1193, "Unknown system variable 'gtid_mode'", "HY000" + ) + closed = [] + messages = [] + monkeypatch.setattr( + session, "_close_database", lambda finalize: closed.append(finalize) + ) + + def message_callback(type, message, result): + messages.append((type, message, result)) + + session._message_callback = message_callback + session.terminate_thread() + + assert closed == [True] + assert messages == [("ERROR", session.thread_error, None)] + assert session._term_complete.is_set() + + class TestDbSessionSqlite: def test_instance(self):