Skip to content

fix(mysql): read SHOW STATUS bare and measure the EXPLAIN grammar at connect (#573, #574) - #599

Merged
cevheri merged 11 commits into
mainfrom
fix/mysql-wire-status-and-explain
Sep 6, 2026
Merged

fix(mysql): read SHOW STATUS bare and measure the EXPLAIN grammar at connect (#573, #574)#599
cevheri merged 11 commits into
mainfrom
fix/mysql-wire-status-and-explain

Conversation

@cevheri

@cevheri cevheri commented Sep 6, 2026

Copy link
Copy Markdown
Member

Fixes #573
Fixes #574

What changed

Two statement forms the MySQL provider sent were MySQL grammar that several MySQL-wire relatives reject. Both are ours, both are fixed, and both fixes were measured against live containers of every relative the registry lists.

#573, Overview and Health on Doris. getHealth(), getOverview() and getPerformanceMetrics() sent SHOW STATUS LIKE '<name>', which Doris rejects as a parse error. Each now sends one bare SHOW STATUS and picks the row client side, case-insensitively, so the filter that was ours no longer costs the panel. The fabricated fallbacks went with it: an absent Threads_connected row omits activeConnections instead of sending 0, an absent Uptime reports N/A and no startTime, and an absent max_connections reports 0 (the type's own "no limit published") instead of the compiled-in 151. SHOW VARIABLES LIKE 'max_connections' stays, because every engine measured accepts it.

#574, Explain on Doris, StarRocks, TiDB, SingleStore. explainFormat: "mysql-json" was a per-provider constant, so the Explain panel always sent EXPLAIN FORMAT=JSON. The provider now measures its EXPLAIN grammar at connect(), on the connection the pool check already holds: EXPLAIN FORMAT=JSON SELECT 1, and only if that is refused, EXPLAIN SELECT 1. The first accepted statement is the capability (mysql-json, the new mysql-text, or none). The probe reads success or failure, never the errno, because the relatives share none (Doris and TiDB answer 1105, StarRocks and SingleStore 1064). Before connect() the provider still answers mysql-json, so the type-driven provider-meta route sees no change.

Because POST /api/db/provider-meta never connects (#457), a runtime capability cannot reach the browser through it. The EXPLAIN statement is therefore built on the server: POST /api/db/query gains explain: { mode }, resolves the connected provider's strategy, runs only the statement it builds (never the caller's SQL), and names explainFormat in the response; the client sends the original statement and stores the plan under the format the response names. Bound params travel with an explain request and bind to the built statement, which keeps PR #304's pre-warmed plan for generated statements.

mysql-text renders the engine's own plain EXPLAIN output as a tree by indentation (TiDB's box glyphs, Doris and StarRocks's indented fragments), with estRows as the only metric when a column of that name carries a number, and the plain text verbatim in the raw tab.

Measured in a real browser (Chrome 151 via Playwright, 2026-09-06), before (a193debb) and after

Engine Test Connection /api/db/health Overview connections card Explain request
MySQL 26.7.0 200 -> 200 200 -> 200 16/151 -> 8/151 200 (json) -> 200 (mysql-json)
MariaDB 12.3.2 200 -> 200 200 -> 200 16/151 -> 8/151 200 (json) -> 200 (mysql-json)
TiDB 8.5.1 200 -> 200 200 -> 200 0 no limit published -> N/A not published 500 explain format 'json' is not supported now -> 200 (mysql-text)
Apache Doris 4.1.3 200 degraded -> 200 500 -> 200 panel unavailable -> N/A not published 500 mismatched input '=' expecting {, ';'}(l -> 200 (mysql-text)
StarRocks 3.3.22 200 degraded -> 200 degraded 500 -> 500 0/151 -> N/A not published 400 Getting syntax error at line 1, column 8. Det -> 200 (mysql-text)
SingleStore 9.1.1 200 -> 200 200 -> 200 19/100000 -> 11/100000 500 You have an error in your SQL syntax; check t -> 200 (mysql-text)
Vitess 24.0.2 not run -> 200 not run -> 200 not run -> 14/1000 not run -> 200 (mysql-json)
OceanBase CE 4.4.2 not run -> 200 not run -> 200 not run -> 9/2147483647 not run -> 200 (mysql-json)
Databend 1.2.925 not run -> 200 degraded not run -> 500 not run -> panel unavailable not run -> 200 (mysql-text)

Wire-level measurements per engine (mysql2 3.24.2, text protocol) are in docs/providers/mysql.md section 5.5. Vitess and OceanBase accept EXPLAIN FORMAT=JSON, so nothing changes for them; Databend has no SHOW STATUS statement at all, so its Overview stays unavailable as its row already said, while its Explain panel now renders its own text plan. StarRocks's health still fails on the missing information_schema.PROCESSLIST, the engine's own.

Registry and docs

Apache Doris moves from partial to full: its row said the tier existed for D30 alone, and every surface answers now. StarRocks, SingleStore and Databend keep their tiers with their Explain and overview sentences rewritten to what renders. BACKLOG D30 and X14 are closed and removed.

Verification

Gates run locally on the branch: format, lint, typecheck, knip, chart:check, channels:showcase:check, readme:check, security:check, test, build:lib, attw, test:coverage and coverage:check (100.00%).

Note for #582 (mysql.md named citations): this branch adds statusValue(), probeExplainFormat() and section 5.5 to mysql.ts and mysql.md, so the line numbers that PR re-derives should be taken from main after this merges. The existing [mysql.ts:<n>] links were left untouched here for that reason.

getHealth(), getOverview() and getPerformanceMetrics() asked for one status
variable at a time with SHOW STATUS LIKE '<name>'. Measured 2026-09-06 over
mysql2 3.24.2's text protocol against apache/doris:all-in-one-4.1.3, that
clause is a parse error there: errno 1105, ER_UNKNOWN_ERROR, HY000,
"mismatched input 'LIKE' expecting {<EOF>, ';'}(line 1, pos 12)", while a bare
SHOW STATUS is accepted and answers 0 rows. So the Overview and Health panels
failed outright on Doris for a filter the statement does not need (#573).

Each method now sends one bare SHOW STATUS and picks the rows it wants by
Variable_name, matched case-insensitively the way LIKE matched. getOverview()
takes Uptime and Threads_connected out of the same result, one round trip
fewer than before. SHOW VARIABLES LIKE 'max_connections' stays: Doris rejects
the clause on SHOW STATUS only, so the change touches only what a grammar
refuses.

The fabricated fallbacks go with it. Measured the same day, the status lists
differ: MySQL 26.7.0 528 rows, MariaDB 12.3.2 571, SingleStore 75, TiDB 8.5.1
13, StarRocks 3.3.22 0, Doris 4.1.3 0. TiDB publishes Uptime and not
Threads_connected; StarRocks and Doris publish neither. An unpublished
variable is now an absent reading: activeConnections is omitted from both
HealthInfo and DatabaseOverview, and startTime is omitted with uptime "N/A".
maxConnections stays a required number because 0 there means "no limit
published", but its || "151" default is gone, which reported MySQL's
compiled-in ceiling for every server that publishes none.

Docs and tests move with the code: mysql.md section 8 states the absence
contract and the measured row counts, and the provider test file drives the
Doris shape from probe-doris.json.
…able

The paragraph was inserted between two rows of the section 8 method table.
In GFM a blank line and a paragraph end a table, so the five rows after it,
from getSlowQueries() to getStorageStats(), rendered as literal pipe text.
It now sits after the last row, before the graceful-degradation list, and its
two em-dashes are commas so the new prose matches the house style.
The MySQL-wire relatives that refuse EXPLAIN FORMAT=JSON accept a plain
EXPLAIN and answer their own text or tabular plan. Measured 2026-09-06 over
mysql2 3.24.2 on the text protocol: TiDB v8.5.1 answers errno 1105 "explain
format 'json' is not supported now", StarRocks 3.3.22 and SingleStore 0.2.82
answer errno 1064, Apache Doris 4.1.3 answers errno 1105 "mismatched input
'=' expecting {<EOF>, ';'}". All four accept the bare EXPLAIN.

The new strategy is shape-driven, never engine-driven: the first column is
the node text, the remaining non-empty columns are the detail, and the
leading run of whitespace and box glyphs on the first column is the nesting.
Doris names its single column "Explain String(Nereids Planner)" and StarRocks
names it "Explain String", which is why nothing here keys on a column name.
An estRows column that parses as a finite number becomes the only metric; no
other number is invented. Blank rows are dropped, StarRocks pads its plan
with two and Doris with six.

The render model's raw is the plain text of the plan, so VisualExplain's raw
tab prints a string verbatim instead of stringifying it into one quoted line
with escaped newlines. plan-summary keeps no reading for the format: a text
plan carries no field the agent layer can read, and unknown is the honest
answer.
POST /api/db/query gains an optional "explain": { "mode": "estimate" |
"analyze" }. When it is present the route resolves the CONNECTED provider's
capabilities, builds the statement with that format's strategy, runs that
statement and names the format back in "explainFormat". The raw sql is never
run for an explain request: no strategy or supportsExplain false is a 400
"This server does not support EXPLAIN", a statement the strategy declines is a
400 "Only SELECT statements can be explained", and an explain request beside a
non-empty params array is a 400, since an explain run describes a statement
rather than running one with values bound into it.

The browser stops building EXPLAIN text. Both explain fetches in
use-query-execution now post the user's own statement plus the mode they use
today (analyze for the direct run, estimate for the background pre-warm), and
store the plan under the format the response names, falling back to the static
strategy when the response names none or names one this build does not
register. The pre-flight refusal stays client side, so a non-SELECT still shows
its toast and makes no request; a parameterized run now makes no plan request
at all, because the route refuses that combination.

Measured 2026-09-06 over the MySQL wire protocol (mysql2 3.24.2, text
protocol): EXPLAIN FORMAT=JSON SELECT 1 is errno 1105 on TiDB v8.5.1 ("explain
format 'json' is not supported now") and Apache Doris 4.1.3 ("mismatched input
'='"), errno 1064 on StarRocks 3.3.22 and SingleStore, while plain EXPLAIN
SELECT 1 is accepted on all of them. POST /api/db/provider-meta never connects
(#457), so the client cannot be told which form to build; the connected
provider is the only place that knows.
…ment

The route refused an explain request that carried bound params. That is the
opposite of the contract PR #304 relies on: every strategy's buildSql only
prefixes the statement, so the built statement carries the same placeholders in
the same order and the same values bind them. The refusal took the pre-warmed
plan away from every generated statement that sends its values separately
(#290), so it is gone and the hook sends params with the background plan request
again, exactly as before.

Also guards the format the response names. getExplainStrategy indexes an object
literal, so an inherited key such as "constructor" resolved to a truthy value
that is not a strategy and reading .format off it threw. A registered strategy
names itself, so that identity is now the guard, with a test for both the
unregistered format and the inherited key.
…gistered format with a 400

Two review findings on the mysql-text strategy and the query route.

readEstRows() ran Number() over the raw cell, and Number("") is 0, so a row
whose estRows the engine left blank rendered a "~0 rows" badge nobody measured.
A blank cell is now an absent reading, per the absence rule (#477). The indent
is now the length the prefix strip removes, with no unreachable null arm.

The route compared the resolved strategy with null, but getExplainStrategy()
indexes a Record and answers undefined for a format outside the union, so an
external provider declaring one reached strategy.buildSql and surfaced as a
TypeError 500 instead of the 400 sentence the user can act on.
… a capability (#574)

The provider declared explainFormat: "mysql-json" for every engine on this
type id, so the Explain panel always sent EXPLAIN FORMAT=JSON. That is MySQL's
own grammar and the wire family does not share it. Measured 2026-09-06 through
mysql2 3.24.2 over the text protocol: TiDB 8.5.1 and Apache Doris 4.1.3 refuse
it with errno 1105, StarRocks 3.3.22 and SingleStore with errno 1064, Databend
1.2.925 with errno 1105 SyntaxException, and every one of them accepts a plain
EXPLAIN SELECT 1. MySQL 26.7.0, MariaDB 12.3.2, Vitess 24.0.2 and OceanBase CE
4.4.2 accept both.

connect() now probes on the pooled connection it already holds: EXPLAIN
FORMAT=JSON SELECT 1 first, then EXPLAIN SELECT 1, and the first that succeeds
names the format getCapabilities() declares. The family shares no errno for a
grammar refusal, so the probe reads success or failure and never the code,
which also keeps src/lib/db free of a branch on the product name. A refusal is
a fact about the Explain panel, not the connection, so it never becomes a
ConnectionError. Before connect() the provider still answers mysql-json, which
is what the type-driven provider-meta route reads and what it read before.

The marketplace copy gate derived its explain-capable engine set by grepping
provider sources for a literal explainFormat line. It now reads the measured
form too, so MySQL stays nameable in a listing's explanation claim.
…6-09-06 re-measurement

The nine MySQL-wire relatives were re-measured on 2026-09-06, at the wire with
mysql2 3.24.2 and then in a browser against the built app. This sweep makes the
registry and the docs say what those runs found.

Apache Doris moves from partial to full. Both surfaces that used to fail were
ours: the overview and health panels sent SHOW STATUS LIKE, which the Doris
grammar rejects, and the Explain panel sent EXPLAIN FORMAT=JSON, which it
rejects too. All fifteen answer now. The overview publishes uptime N/A and
Connections N/A, not published, because Doris answers a bare SHOW STATUS and
SHOW VARIABLES LIKE 'max_connections' with zero rows each.

StarRocks stays partial: health, sessions and the monitoring dashboard have no
information_schema.PROCESSLIST, which is the engine's own. Its overview no
longer fabricates 0/151, and its Explain panel renders StarRocks's own text
plan, 14 rows drawn as a 13-node tree.

TiDB stays full: its Explain panel renders TiDB's own operator tree with
estRows as the row estimate, and the connection count reads not published
because its SHOW STATUS has 13 rows and no Threads_connected among them.

SingleStore stays partial (row counts and sizes are zeros in
information_schema.TABLES) and its Explain panel renders the engine's own text
plan; EXPLAIN FORMAT=JSON is still a parse error there and is no longer sent.

Databend stays query-only: the Explain panel renders its own text plan, and the
overview, health and session panels stay unavailable because Databend has no
SHOW STATUS statement at all.

Vitess and OceanBase are unchanged; both accept EXPLAIN FORMAT=JSON.

D30 and X14 are deleted from docs/BACKLOG.md with their index counts corrected,
and no reference to either id remains in the repository.
The Phase C sweep left three sentences in the registry that contradicted
what the 2026-08-24 and 2026-09-06 measurements recorded elsewhere.

StarRocks: the caveat said the whole monitoring dashboard was unavailable.
Since 2026-08-24 each panel is read independently and getMonitoringData()
returns overview, performance, slowQueries, tables, indexes and storage,
with only errors.activeSessions carrying the engine's sentence. The caveat
now names the health request and the active-session panel, which are the
two surfaces the missing information_schema.PROCESSLIST actually costs.

SingleStore: the caveat still claimed in the present tense that Test
Connection, health, the overview and the monitoring dashboard all fail on
the prepared-statement message. Those four failures were ours and were
fixed on 2026-08-24 when every parameterless statement moved to MySQL's
text protocol; the sentence is now past tense and names that fix, keeping
the ten-of-fifteen count from the first probe. Its companion sentence
said no version is displayed anywhere, which followed from the overview
being unavailable; the overview renders, so both the registry and the
docs/providers/README.md row now record the version it shows, MySQL
5.7.32, the wire version SingleStore advertises.

The registry docblock pointed both #573 and #574 at mysql.md section 5.5.
Section 5.5 carries the EXPLAIN grammar probe, section 8 the SHOW STATUS
reads, so the pointer names both.

The e2e comment above the Databend assertion cited Doris as the precedent
for asserting a tier suffix; Doris is full since 2026-09-06 and its suffix
is now asserted absent, so the comment cites StarRocks instead.
… registry and the Docker Hub listing

Review findings on the Phase C sweep. The SingleStore README row opened in the
present tense with a count from its first probe; it now says every surface answers
since 2026-09-06 and why the row stays Partial (zeros, not measurements). Two
2026-08-24 StarRocks sentences that the 2026-09-06 Overview re-measurement
supersedes now say so. The StarRocks registry caveat claimed the rest of the
dashboard renders, which was a provider-level reading, and now says exactly that.
The Doris registry test comment counted StarRocks at eleven surfaces; it now names
the two it still loses. DOCKERHUB.md carried the pre-fix tier table: Doris is Full,
StarRocks's overview renders, and SingleStore's Explain shows the engine's own plan.
@codecov

codecov Bot commented Sep 6, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@sonarqubecloud

sonarqubecloud Bot commented Sep 6, 2026

Copy link
Copy Markdown

@cevheri
cevheri merged commit e11015a into main Sep 6, 2026
27 checks passed
@cevheri
cevheri deleted the fix/mysql-wire-status-and-explain branch September 6, 2026 13:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant