Skip to content

fix(mysql): a MariaDB server read as MySQL, and an unmeasured metric read as 99% (#424) - #458

Merged
cevheri merged 1 commit into
mainfrom
fix/mysql-vendor-label-and-absent-metrics
Aug 22, 2026
Merged

cevheri merged 1 commit into
mainfrom
fix/mysql-vendor-label-and-absent-metrics

Conversation

@kaya-abdullah

Copy link
Copy Markdown
Member

Found while connecting the product to MariaDB 12.3 and MySQL 26.7 side by side, to check the claims a page for the MariaDB documentation would have to make. Both defects are in our MySQL provider, not in either engine.

The overview named the wrong vendor

getOverview() returned MySQL ${version} unconditionally. A MariaDB server whose VERSION() answers 12.3.2-MariaDB-ubu2404 was published as "MySQL 12.3.2-MariaDB-ubu2404" — an assertion the server never made, and the first thing anyone opening the product against MariaDB would see.

The same statement was wrong for every relative whose version string names itself. All four are strings WIRE_COMPATIBLE_ENGINES already records from a live probe:

Engine VERSION() Was published as
MariaDB 12.3.2-MariaDB-ubu2404 MySQL 12.3.2-MariaDB-ubu2404
TiDB 8.0.11-TiDB-v8.5.1 MySQL 8.0.11-TiDB-v8.5.1
Vitess 8.0.43-Vitess MySQL 8.0.43-Vitess
OceanBase 5.7.25-OceanBase_CE-v4.4.2.1 MySQL 5.7.25-OceanBase_CE-v4.4.2.1

A version string that names its own vendor is now passed through unchanged; only a bare number gets the MySQL prefix. StarRocks and SingleStore are deliberately not on that list — both answer with a plain MySQL number and give nothing to key on, which the compatibility table already records as their behaviour.

An unmeasured metric read as 99%

MariaDB ships performance_schema OFF by default. Measured on mariadb:12.3, @@performance_schema = 0, and the tables still exist — so the metric queries do not fail. Each is a bare SELECT (subquery) that answers one row of NULLs.

parseFloat(row?.hit_ratio || "99") turned that NULL into a confident 99% cache hit ratio, and the buffer-pool and QPS defaults turned theirs into 0. Connected in the browser, a stock MariaDB server reported a healthy 99% cache for a figure nobody had taken.

This is the fault #448 and #452 removed from the panels, still live in the provider that feeds them. ABSENCE and ZERO are different inputs, and only the first must stay invisible. Every reading now goes through measuredNumber(), a field with nothing behind it is omitted, and if performance_schema is absent outright the method returns {} rather than a set of defaults. getHealth() carried the same fabricated 99 and now uses the shared formatCacheHitRatio().

What the fix uncovered

Innodb_deadlocks is MariaDB's status variable. MySQL does not publish it: SHOW STATUS LIKE 'Innodb_deadlocks' is empty on both 8.0.46 and 26.7.0, measured. So parseInt(row?.Value || "0") had been reporting a deadlock count MySQL never gave. It is now absent there and present on MariaDB, which makes it the one performance figure a default MariaDB reports and a MySQL server does not.

Measured after the change

Same two connections, same session, through /api/db/monitoring:

MySQL 26.7    version "MySQL 26.7.0"
              cacheHitRatio 96.49, queriesPerSecond 0.03, bufferPoolUsage 14.89

MariaDB 12.3  version "12.3.2-MariaDB-ubu2404"
              deadlocks 0, and nothing else claimed

Checks

Written test-first: the six new tests failed with Received: "MySQL 12.3.2-MariaDB-ubu2404" and Received: 99 before the implementation landed. They cover the vendor label for all four probed strings, the plain-MySQL case, performance_schema off, performance_schema absent outright, and a server that publishes no deadlock counter.

format, lint, typecheck, knip, test and build all pass locally; merged coverage stays at 100% (40321/40321 lines).

Docs move with the code, per the provider triad rule: docs/providers/mysql.md gains a section on the MySQL-protocol engines and rewrites the monitoring degradation notes, and both the compatibility registry and its published table record the performance_schema default as measured rather than assumed.

…read as 99% (#424)

Two defects found while connecting the product to MariaDB 12.3 and MySQL 26.7 side by
side, both in the MySQL provider rather than in either engine.

The overview named the wrong vendor. `getOverview()` returned `MySQL ${version}`
unconditionally, so a MariaDB server whose `VERSION()` answers `12.3.2-MariaDB-ubu2404`
was published as "MySQL 12.3.2-MariaDB-ubu2404" - an assertion the server never made.
The same statement was wrong for every relative whose version string names itself:
TiDB `8.0.11-TiDB-v8.5.1`, Vitess `8.0.43-Vitess` and OceanBase
`5.7.25-OceanBase_CE-v4.4.2.1`, all four being the strings WIRE_COMPATIBLE_ENGINES
already records from a live probe. A version string that names its own vendor is now
passed through unchanged, and only a bare number gets the `MySQL ` prefix. StarRocks and
SingleStore are deliberately not on that list: both answer with a plain MySQL number and
give nothing to key on, which the compatibility table already records as their behaviour.

`getPerformanceMetrics()` manufactured measurements. MariaDB ships `performance_schema`
OFF by default (measured on `mariadb:12.3`: `@@performance_schema` = 0) and the tables
still exist, so the metric queries do not fail - each is a bare `SELECT (subquery)` that
answers one row of NULLs. `parseFloat(row?.hit_ratio || "99")` turned that NULL into a
confident 99% cache hit ratio, and the buffer pool and QPS defaults turned theirs into
0. Connected in the browser, a stock MariaDB server therefore reported a healthy 99%
cache for a figure nobody had taken. This is the fault #448 and #452 removed from the
panels, still live in the provider that feeds them: ABSENCE and ZERO are different
inputs, and only the first must stay invisible. Every reading now goes through
`measuredNumber()` and a field with nothing behind it is omitted; if `performance_schema`
is absent outright the method returns `{}` rather than a set of defaults.

`getHealth()` had the same fabricated 99, and now uses the shared `formatCacheHitRatio()`,
which is the one place that already turns an absent ratio into "N/A".

One thing the fix uncovered: `Innodb_deadlocks` is MariaDB's status variable and MySQL
does not publish it. `SHOW STATUS LIKE 'Innodb_deadlocks'` is empty on both MySQL 8.0.46
and 26.7.0, so `parseInt(row?.Value || "0")` had been reporting a deadlock count MySQL
never gave. It is now absent there and present on MariaDB, which makes it the one
performance figure a default MariaDB reports and a MySQL server does not.

Measured after the change, same two connections, same session:

    MySQL 26.7    version "MySQL 26.7.0"
                  cacheHitRatio 96.49, queriesPerSecond 0.03, bufferPoolUsage 14.89
    MariaDB 12.3  version "12.3.2-MariaDB-ubu2404"
                  deadlocks 0, and nothing else claimed

Docs and tests move with the code. `docs/providers/mysql.md` gains a section on the
MySQL-protocol engines and rewrites the monitoring degradation notes; the compatibility
registry and its published table record the `performance_schema` default as measured
rather than assumed. Six new provider tests cover the vendor label for all four probed
strings, the plain-MySQL case, `performance_schema` off, `performance_schema` absent, and
a server that publishes no deadlock counter.
@kaya-abdullah
kaya-abdullah requested a review from cevheri August 21, 2026 23:53
@cevheri cevheri self-assigned this Aug 21, 2026
@sonarqubecloud

Copy link
Copy Markdown

@cevheri
cevheri merged commit bf85f88 into main Aug 22, 2026
22 checks passed
@cevheri
cevheri deleted the fix/mysql-vendor-label-and-absent-metrics branch August 22, 2026 08:32
cevheri added a commit that referenced this pull request Aug 23, 2026
…t had done, plus five more (#469)

* fix(providers): every MySQL DDL and DML reported a failure for work it had done, plus five more

Six defects, each measured against a live server before and after. Four were BACKLOG
entries; two were found while closing them and are the most serious of the six.

Every non-SELECT statement on MySQL threw. `query()` called `.map` on mysql2's
first return value, which is a `ResultSetHeader` object rather than an array for
DDL and DML, so `CREATE TABLE`, `INSERT`, `UPDATE` and `DELETE` each answered
`result.rows.map is not a function` AFTER the server had applied the statement -
the answer that makes a user retry and double-apply it. Measured through the
provider with an interleaved SELECT proving each statement had landed, and again
in the browser: a `CREATE TABLE` from the editor now reports "0 rows" with the
table created, and a two-row `INSERT` reports "2 rows". The tests could not see
it because they mocked `execute` as returning an array; the mock type is now wide
enough to express the real shape, which is what hid it.

X7 - the DDL export typed a numeric or a timestamp column as TEXT, because the
wire hands it a string. The inference was never at fault: it never saw a type.
`pg`, `mysql2`, `oracledb` and `mssql` now fill `QueryResult.columnTypes` with the
engine's own spelling, joining the six provider families that already did. The
mappings were measured, not remembered: Postgres resolves its OIDs through a
generated `pg_type` table (149 built-ins; verified over dvdrental as 128 columns,
125 named, 0 wrong), and MySQL's shared type codes are separated by charset 63 and
the length tiers rather than by assumption (38 of 39 match
`information_schema.DATA_TYPE`; `POINT` and `GEOMETRY` are indistinguishable on the
wire). In the browser a Postgres result now reads `price numeric`,
`ts timestamp without time zone`, `b bytea` in the grid, and its exported DDL says
the same instead of TEXT.

That change had a consequence which is fixed in the same PR rather than left to be
discovered: a declared type is a BARE base name, because a length cannot be
recovered from the wire, and a bare name is not legal DDL everywhere. Measured by
replay - MySQL `ERROR 1064`, Oracle `ORA-00906`, and SQL Server accepting it while
silently narrowing `nvarchar` to length 1. The export now completes a bare name to
its target dialect's unbounded form, and re-spells a foreign engine's declaration
rather than writing a `VARCHAR2` into Postgres DDL, which the shells make reachable
by keeping the connection type beside the tab's own result. Every arm replayed into
its own engine and read back out of its catalog.

X8 - the SQL INSERT and DDL exports wrote a binary value as its JSON shape, so a
replayed INSERT stored 46 bytes of `{"type":"Buffer","data":[...]}` where six bytes
belonged, successfully and silently. Each dialect now writes a literal its own
engine accepts, every spelling measured rather than assumed: Postgres's `X'...'` is
a bit string and cannot be cast to bytea, MySQL has no `0x` spelling for an empty
value, SQL Server rejects the standard form, and Oracle parses neither so the
conversion function is the literal. Replayed into seven engines, six bytes each.

D7 - three providers answered the performance panel with a fabricated cache hit
ratio, and the sweep found five more. All eight are honest now: MySQL's health read
no longer dies on a tenant with no `performance_schema` DATABASE (measured on
OceanBase as ERROR 1049, where the entry's own three sites had already been fixed
by #458), and SQLite, LibreDB, MongoDB, Couchbase, Postgres, Oracle and SQL Server
report an unmeasurable ratio as absent, which #452's tabs already render as "Not
measured". Two of those fabrications were pinned by tests - one named "reports zero
rather than a perfect score when denied" - and a denial is the ordinary case, so the
panel was inventing a red critical cache fault. `bufferPoolUsage` went with them in
three providers: it was the cache hit ratio wearing a second name, drawn and rated
as an independent gauge. In the browser: MySQL 98.8%, Oracle 99.5%, SQLite "Not
measured" where it used to claim a fixed 95%.

D12 - a pasted `rediss://` URL landed on SSL mode `disable`, so the scheme was
silently dropped. It now arrives as `require`, and so does `couchbases://`, whose
excuse for being left out ("the provider re-reads the scheme") was untrue: that
transport picks http or https from `config.ssl` alone and was posting plaintext to
18091. `mongodb+srv://` is deliberately left unset - the driver turns TLS on itself
WITH chain verification, and carrying our `require` there would have stopped an
Atlas certificate being checked. Verified in the browser and end to end against a
TLS-only Redis, both arms.

Follow-ups recorded rather than smuggled in: D13 (Oracle answers every non-SELECT
with `rowCount: 0`, including rows it just wrote), D14 (what a real buffer-pool
gauge would cost on each engine), D15 (SQLite's table size is `rowCount * 100`),
D16 (the TLS a connection string carries in its query string is still dropped), X9
(what `columnTypes` still cannot name) and X10 (MySQL and Cassandra stringify their
bytes before the export can see them, which bounds what X8 closed).

* docs(export): the reason a dialect is absent from STANDS_ALONE covered only half the cases

An external review of #469 approved it and read the absence of the other dialects as
settled. It is not, and the module's own comment is why: it justified having no row for
clickhouse/trino/sqlite/cassandra by saying those engines spell their types out in full,
which is true of the names THEY declare and says nothing about a name arriving from
somewhere else. Measured under each target for one Oracle result: postgres writes TEXT,
mysql TEXT, mssql NVARCHAR(MAX) - and clickhouse, plus the no-connection default, write
VARCHAR2, BINARY_DOUBLE and CLOB, which replay nowhere. The no-connection case is a
regression: a value-shaped guess used to write portable standard SQL there.

Widening the rule is not the fix - Trino's own bare varchar is legal and unbounded and
would become a TEXT it does not have - so each remaining dialect needs a measured row,
recorded as BACKLOG X11. Comment and entry only, no behaviour change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants