From e2b0e5f08f45feef7ab06684f05494a4e326fc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ahmet=20So=C4=9Fuksu?= Date: Fri, 7 Aug 2026 17:31:49 +0300 Subject: [PATCH 1/2] Fix read_only scan error on MariaDB via CASE-WHEN string check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MariaDB (observed on 12.3.2) returns @@global.read_only as a string ('OFF'/'ON') rather than an integer when queried via certain client protocols, causing a Go sql/driver scan error: sql: Scan error on column index 4, name "@@global.read_only": sql/driver: couldn't convert "OFF" into type bool This breaks discovery entirely on affected MariaDB versions. An earlier attempt cast the value to UNSIGNED (CAST(@@global.read_only AS UNSIGNED)) to force MariaDB to return a plain integer. Lab testing across MariaDB 10.11/11.4/11.8/12.3 and MySQL 8.4/9.7 showed this is unsafe: on some MariaDB versions/protocols CAST(... AS UNSIGNED) on the string 'ON' evaluates to 0 instead of 1, silently reporting a read-only replica as writable. Replaced with an explicit string comparison that only treats '1' or 'ON' (case-insensitive) as read-only=true, everything else (including unexpected values) defaults safely to not-read-only=false becoming true only on explicit match: CASE WHEN CAST(@@global.read_only AS CHAR) IN ('1','ON','on') THEN 1 ELSE 0 END Verified via full failover test cycles (discovery, promotion, ProxySQL hostgroup swap, rejoin) against MariaDB 10.11.18, 11.4.12, 11.8.8, 12.3.2 and MySQL 8.4.11, 9.7.2 — zero regressions vs the official v4.31.1 release on the 5 versions unaffected by this bug, and fixes discovery entirely on MariaDB 12.3.2. Signed-off-by: Ahmet Soğuksu --- go/inst/instance_dao.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go/inst/instance_dao.go b/go/inst/instance_dao.go index 9f548118..e7f3d8d3 100644 --- a/go/inst/instance_dao.go +++ b/go/inst/instance_dao.go @@ -673,7 +673,7 @@ func ReadTopologyInstanceBufferable(instanceKey *InstanceKey, bufferWrites bool, // Synchronously query for some params needed in following go routines var mysqlHostname, mysqlReportHost string - err = db.QueryRow("select @@global.hostname, ifnull(@@global.report_host, ''), @@global.server_id, @@global.version_comment, @@global.read_only, @@global.binlog_format, @@global.log_bin, @@global."+instance.QSP.log_slave_updates()).Scan( + err = db.QueryRow("select @@global.hostname, ifnull(@@global.report_host, ''), @@global.server_id, @@global.version_comment, CASE WHEN CAST(@@global.read_only AS CHAR) IN ('1','ON','on') THEN 1 ELSE 0 END, @@global.binlog_format, @@global.log_bin, @@global."+instance.QSP.log_slave_updates()).Scan( &mysqlHostname, &mysqlReportHost, &instance.ServerID, &instance.VersionComment, &instance.ReadOnly, &instance.Binlog_format, &instance.LogBinEnabled, &instance.LogReplicationUpdatesEnabled) if err != nil { goto Cleanup From 00cf2547538ba2c4f1ec8841f129d8309458774a Mon Sep 17 00:00:00 2001 From: Rene Cannao Date: Tue, 11 Aug 2026 16:31:10 +0000 Subject: [PATCH 2/2] test(inst): cover MariaDB read_only modes --- .github/workflows/functional.yml | 8 ++- go/inst/instance_dao.go | 2 +- tests/functional/docker-compose.mariadb.yml | 14 +++++ tests/functional/test-mariadb-read-only.sh | 65 +++++++++++++++++++++ 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100755 tests/functional/test-mariadb-read-only.sh diff --git a/.github/workflows/functional.yml b/.github/workflows/functional.yml index 38e53a0f..7ecf5011 100644 --- a/.github/workflows/functional.yml +++ b/.github/workflows/functional.yml @@ -191,7 +191,7 @@ jobs: strategy: fail-fast: false matrix: - mariadb_version: ['10.6', '10.11'] + mariadb_version: ['10.6', '10.11', '12.3'] steps: - uses: actions/checkout@v4 @@ -274,6 +274,12 @@ jobs: COMPOSE: docker compose -f tests/functional/docker-compose.yml -f tests/functional/docker-compose.mariadb.yml run: bash tests/functional/test-smoke.sh + - name: Run read_only discovery tests + env: + MYSQL_IMAGE: mariadb:${{ matrix.mariadb_version }} + COMPOSE: docker compose -f tests/functional/docker-compose.yml -f tests/functional/docker-compose.mariadb.yml + run: bash tests/functional/test-mariadb-read-only.sh + - name: Run relay drain / SQL-stopped failover tests env: MYSQL_IMAGE: mariadb:${{ matrix.mariadb_version }} diff --git a/go/inst/instance_dao.go b/go/inst/instance_dao.go index e7f3d8d3..43d7b73b 100644 --- a/go/inst/instance_dao.go +++ b/go/inst/instance_dao.go @@ -673,7 +673,7 @@ func ReadTopologyInstanceBufferable(instanceKey *InstanceKey, bufferWrites bool, // Synchronously query for some params needed in following go routines var mysqlHostname, mysqlReportHost string - err = db.QueryRow("select @@global.hostname, ifnull(@@global.report_host, ''), @@global.server_id, @@global.version_comment, CASE WHEN CAST(@@global.read_only AS CHAR) IN ('1','ON','on') THEN 1 ELSE 0 END, @@global.binlog_format, @@global.log_bin, @@global."+instance.QSP.log_slave_updates()).Scan( + err = db.QueryRow("select @@global.hostname, ifnull(@@global.report_host, ''), @@global.server_id, @@global.version_comment, CASE WHEN UPPER(CAST(@@global.read_only AS CHAR)) IN ('0','OFF') THEN 0 ELSE 1 END, @@global.binlog_format, @@global.log_bin, @@global."+instance.QSP.log_slave_updates()).Scan( &mysqlHostname, &mysqlReportHost, &instance.ServerID, &instance.VersionComment, &instance.ReadOnly, &instance.Binlog_format, &instance.LogBinEnabled, &instance.LogReplicationUpdatesEnabled) if err != nil { goto Cleanup diff --git a/tests/functional/docker-compose.mariadb.yml b/tests/functional/docker-compose.mariadb.yml index b31c73f4..c6936ef5 100644 --- a/tests/functional/docker-compose.mariadb.yml +++ b/tests/functional/docker-compose.mariadb.yml @@ -1,17 +1,31 @@ # Override for MariaDB functional tests (use with docker-compose.yml) # MYSQL_IMAGE=mariadb:10.11 docker compose -f docker-compose.yml -f docker-compose.mariadb.yml up -d +x-mariadb-client-compat: &mariadb-client-compat + command: mariadbd + entrypoint: + - /bin/sh + - -c + - | + ln -sf /usr/bin/mariadb /usr/local/bin/mysql + ln -sf /usr/bin/mariadb-admin /usr/local/bin/mysqladmin + exec docker-entrypoint.sh "$@" + - -- + services: mysql1: + <<: *mariadb-client-compat image: ${MYSQL_IMAGE:-mariadb:10.11} volumes: - ./mysql/mariadb-master.cnf:/etc/mysql/conf.d/repl.cnf - ./mysql/init-master.sql:/docker-entrypoint-initdb.d/init.sql mysql2: + <<: *mariadb-client-compat image: ${MYSQL_IMAGE:-mariadb:10.11} volumes: - ./mysql/mariadb-replica.cnf:/etc/mysql/conf.d/repl.cnf - ./mysql/init-replica.sql:/docker-entrypoint-initdb.d/init.sql mysql3: + <<: *mariadb-client-compat image: ${MYSQL_IMAGE:-mariadb:10.11} volumes: - ./mysql/mariadb-replica2.cnf:/etc/mysql/conf.d/repl.cnf diff --git a/tests/functional/test-mariadb-read-only.sh b/tests/functional/test-mariadb-read-only.sh new file mode 100755 index 00000000..bc029956 --- /dev/null +++ b/tests/functional/test-mariadb-read-only.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Verify MariaDB read_only enum values are normalized correctly by discovery. +set -uo pipefail +cd "$(dirname "$0")/../.." || exit 1 +source tests/functional/lib.sh + +echo "=== MARIADB READ_ONLY DISCOVERY TESTS ===" + +COMPOSE="${COMPOSE:-docker compose -f tests/functional/docker-compose.yml -f tests/functional/docker-compose.mariadb.yml}" +TEST_INSTANCE="mysql2" + +restore_read_only() { + $COMPOSE exec -T "$TEST_INSTANCE" mysql -uroot -ptestpass \ + -e "SET GLOBAL read_only=ON" >/dev/null 2>&1 || true + curl -s --max-time 10 "$ORC_URL/api/discover/$TEST_INSTANCE/3306" >/dev/null 2>&1 || true +} +trap restore_read_only EXIT + +wait_for_orchestrator || { echo "FATAL: Orchestrator not reachable"; exit 1; } +discover_topology "mysql1" || { echo "FATAL: Topology not discovered"; exit 1; } + +wait_for_api_read_only() { + local EXPECTED="$1" + local ACTUAL="" + for _ in $(seq 1 20); do + curl -s --max-time 10 "$ORC_URL/api/discover/$TEST_INSTANCE/3306" >/dev/null 2>&1 + ACTUAL=$(curl -s --max-time 10 "$ORC_URL/api/instance/$TEST_INSTANCE/3306" 2>/dev/null | python3 -c \ + "import json,sys; print(str(json.load(sys.stdin).get('ReadOnly')).lower())" 2>/dev/null || echo "") + if [ "$ACTUAL" = "$EXPECTED" ]; then + return 0 + fi + sleep 1 + done + echo "last API ReadOnly value: ${ACTUAL:-unavailable}" + return 1 +} + +check_mode() { + local MODE="$1" + local EXPECTED="$2" + if ! $COMPOSE exec -T "$TEST_INSTANCE" mysql -uroot -ptestpass \ + -e "SET GLOBAL read_only=$MODE" >/dev/null 2>&1; then + fail "MariaDB rejected read_only=$MODE" + return + fi + if wait_for_api_read_only "$EXPECTED"; then + pass "read_only=$MODE is reported as ReadOnly=$EXPECTED" + else + fail "read_only=$MODE was not reported as ReadOnly=$EXPECTED" + fi +} + +check_mode OFF false +check_mode ON true + +MARIADB_MAJOR="$(mysql_version)" +MARIADB_MAJOR="${MARIADB_MAJOR%%.*}" +if [ "$MARIADB_MAJOR" -ge 12 ]; then + check_mode NO_LOCK true + check_mode NO_LOCK_NO_ADMIN true +else + skip "NO_LOCK modes require MariaDB 12 or newer" +fi + +summary