Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
b6e5aac
vtgate: add VEXPLAIN MYSQLPLAN statement
ejortegau Aug 10, 2026
6b9a248
vtgate: make VEXPLAIN MYSQLPLAN support check an allowlist
ejortegau Aug 10, 2026
40aa115
vtgate: test VEXPLAIN MYSQLPLAN rejects INSERT ... SELECT
ejortegau Aug 10, 2026
6a70115
vtgate: mirror no-route fallback in VEXPLAIN MYSQLPLAN
ejortegau Aug 10, 2026
2faefeb
changelog: document VEXPLAIN MYSQLPLAN baseline-plan limitation
ejortegau Aug 10, 2026
12cabf9
vtgate: bind __upper_limit before EXPLAIN for pushed-down scatter limits
ejortegau Aug 10, 2026
42c6562
vtgate: support explicitly targeted Send plans in VEXPLAIN MYSQLPLAN
ejortegau Aug 10, 2026
c83065a
vtadmin: route VEXPLAIN MYSQLPLAN through VExplain result conversion
ejortegau Aug 11, 2026
1e0da3f
vtgate: reject VEXPLAIN MYSQLPLAN in reserved-connection sessions
ejortegau Aug 12, 2026
c55ccbf
vtgate: reject sequence next-value queries in VEXPLAIN MYSQLPLAN
ejortegau Aug 12, 2026
cc71f06
vtgate: bound VEXPLAIN MYSQLPLAN fanout and account for its shard que…
ejortegau Aug 12, 2026
5026e1d
vtgate: copy bind vars per input when collecting MYSQLPLAN tasks
ejortegau Aug 13, 2026
b56aad1
changelog: document VEXPLAIN MYSQLPLAN per-shard EXPLAIN failure beha…
ejortegau Aug 13, 2026
46513b3
vtgate: unify VEXPLAIN plan-description walkers
ejortegau Aug 13, 2026
dd306e7
vtgate: count only successfully-explained shards in MYSQLPLAN accounting
ejortegau Aug 13, 2026
878891b
Merge remote-tracking branch 'upstream/main' into ejortegau/vexplain-…
ejortegau Aug 13, 2026
b811b48
test/e2e: add unsharded keyspace for VEXPLAIN MYSQLPLAN reserved-conn…
ejortegau Aug 14, 2026
cf692e6
vtgate: reject nested query blocks in VEXPLAIN MYSQLPLAN at plan time
ejortegau Aug 17, 2026
b8593e6
vtgate: reject VEXPLAIN MYSQLPLAN sequence queries before bypass plan…
ejortegau Aug 17, 2026
0b6608e
vtgate: count completed shards when a MYSQLPLAN EXPLAIN fails
ejortegau Aug 17, 2026
f8fc5c1
vtgate: count every attempted shard in MYSQLPLAN accounting
ejortegau Aug 18, 2026
5ef7ad2
vtgate: reject VEXPLAIN MYSQLPLAN advisory lock functions
ejortegau Aug 18, 2026
bc3d25b
vtgate: don't pin a VEXPLAIN MYSQLPLAN session for a SET_VAR-only sysvar
ejortegau Aug 19, 2026
2b9f32f
vtgate: reject VEXPLAIN MYSQLPLAN of SQL_CALC_FOUND_ROWS with GROUP B…
ejortegau Aug 19, 2026
0355407
docs: note VEXPLAIN MYSQLPLAN does not table-ACL-check per-shard EXPL…
ejortegau Aug 19, 2026
e52ccf9
vtgate: give VEXPLAIN MYSQLPLAN derived-table/view rejection an hones…
ejortegau Aug 19, 2026
04dbb37
vtgate: make MYSQLPLAN shard-query accounting an optional VCursor cap…
ejortegau Aug 19, 2026
bdca6b0
vtgate: run VEXPLAIN MYSQLPLAN EXPLAINs through the shared scatter path
ejortegau Aug 20, 2026
6cdbdda
vtgate: drop dead MergeSort entry from VEXPLAIN MYSQLPLAN allowlist
ejortegau Aug 20, 2026
ce2eef5
test/e2e: probe MySQL version from the sharded keyspace by name
ejortegau Aug 20, 2026
421d6f3
test/e2e: cover VEXPLAIN MYSQLPLAN scatter fan-out across shards
ejortegau Aug 20, 2026
afb986f
vtgate: rename MYSQLPLAN output key to mysql_explain_json_by_shard
ejortegau Aug 20, 2026
5880984
vtgate: fold VEXPLAIN MYSQLPLAN AST checks into one walk
ejortegau Aug 20, 2026
ac3f349
vtgate: reject VEXPLAIN MYSQLPLAN DML by statement type
ejortegau Aug 20, 2026
e5cd381
vtgate: move mysqlExplainTask into the top type() block
ejortegau Aug 20, 2026
44077e6
vtgate: warn when a MYSQLPLAN shard returns an empty EXPLAIN result
ejortegau Aug 20, 2026
eaee2cc
changelog: note VEXPLAIN MYSQLPLAN in-transaction reads pre-tx state
ejortegau Aug 20, 2026
aa5d20d
test/e2e: assert VEXPLAIN MYSQLPLAN of INTO OUTFILE/DUMPFILE has no s…
ejortegau Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions changelog/25.0/25.0.0/summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
- [Preparing a statement no longer starts an implicit transaction](#vtgate-prepare-no-implicit-tx)
- [Stricter validation of SQL-level PREPARE statements](#vtgate-prepare-stricter-validation)
- [Stricter PROXY protocol v1 header validation](#vtgate-proxy-protocol-v1-strictness)
- [New `VEXPLAIN MYSQLPLAN` statement](#vtgate-vexplain-mysqlplan)
- **[Reparent](#minor-changes-reparent)**
- [`EmergencyReparentShard` no longer waits on replicas that cannot win the election](#ers-lagging-relay-log-wait)
- [`EmergencyReparentShard` can explicitly recover from split brain](#ers-allow-split-brain-promotion)
Expand Down Expand Up @@ -268,6 +269,22 @@ Specification-conformant v1 headers, as emitted by HAProxy, AWS load balancers,

See [#20733](https://github.com/vitessio/vitess/pull/20733) for details.

#### <a id="vtgate-vexplain-mysqlplan"/>New `VEXPLAIN MYSQLPLAN` statement</a>

A new `VEXPLAIN MYSQLPLAN <query>` statement runs MySQL's `EXPLAIN FORMAT=JSON` against the shards a `SELECT` would target, **without executing the query itself**. It resolves each `Route`'s target shards from its vindex at resolution time and issues `EXPLAIN` against every resolved shard, attaching the per-shard MySQL plan to the VTGate plan tree keyed by shard, so per-shard plan and cost differences are visible.

Unlike `VEXPLAIN ALL`, which executes the query to discover the shard-level queries before explaining them, `VEXPLAIN MYSQLPLAN` never runs the wrapped query.

Only `SELECT` statements whose target shards can be resolved from a vindex without reading cluster data are supported. DML (`INSERT`/`UPDATE`/`DELETE`), and any query whose shard set depends on data — cross-shard joins, subqueries, recursive CTEs, and lookup vindexes — are rejected with an error suggesting `VEXPLAIN ALL` instead. Derived tables, views, and common table expressions are likewise unsupported: `EXPLAIN FORMAT=JSON` can materialize a derived table during optimization (running any stored function inside it once per shard), which would break the promise never to run the wrapped query.

For queries eligible for deferred plan optimization (where equal bind variable values let the plan collapse to a single shard at execution time), `VEXPLAIN MYSQLPLAN` explains the general (baseline) plan rather than the value-specific optimized one, so it reports the full shard footprint the query can target regardless of the bind variable values supplied.

The per-shard `EXPLAIN` queries are run concurrently. If the `EXPLAIN` against any targeted shard fails (for example, an unreachable shard), the whole `VEXPLAIN MYSQLPLAN` command fails with that error rather than returning a partial result — matching the default all-or-nothing behavior of a scatter query.

Because each per-shard `EXPLAIN` runs on a separate connection, a `VEXPLAIN MYSQLPLAN` issued inside an open transaction reflects the pre-transaction state of each shard rather than any uncommitted changes made in that transaction — the same limitation as `VEXPLAIN ALL`.

Like a plain `EXPLAIN`, the per-shard `EXPLAIN FORMAT=JSON` queries `VEXPLAIN MYSQLPLAN` issues are not subject to table ACL checks on the explained tables, so `VEXPLAIN MYSQLPLAN` can return per-shard plan metadata (index names, row estimates, filtered percentages) for tables the caller could not otherwise read. Unlike a plain `EXPLAIN`, which reaches a single arbitrary shard, `VEXPLAIN MYSQLPLAN` extends this to every resolved shard of every keyspace in the plan. Query denylist rules still apply. Deployments that rely on table ACLs to restrict read access should restrict access to `VEXPLAIN MYSQLPLAN` accordingly.

### <a id="minor-changes-reparent"/>Reparent</a>

#### <a id="ers-lagging-relay-log-wait"/>`EmergencyReparentShard` no longer waits on replicas that cannot win the election</a>
Expand Down
21 changes: 21 additions & 0 deletions go/test/endtoend/vtgate/queries/vexplain/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ var (
clusterInstance *cluster.LocalProcessCluster
vtParams mysql.ConnParams
shardedKs = "ks"
unshardedKs = "uks"

shardedKsShards = []string{"-40", "40-80", "80-c0", "c0-"}
Cell = "test"
Expand All @@ -40,6 +41,12 @@ var (

//go:embed vschema.json
shardedVSchema string

//go:embed unsharded_schema.sql
unshardedSchemaSQL string

//go:embed unsharded_vschema.json
unshardedVSchema string
)

func TestMain(m *testing.M) {
Expand All @@ -55,6 +62,20 @@ func TestMain(m *testing.M) {
return 1
}

// Start unsharded keyspace. VEXPLAIN MYSQLPLAN's reserved-connection test
// creates a temporary table, which Vitess only allows on an unsharded
// keyspace.
uKs := &cluster.Keyspace{
Name: unshardedKs,
SchemaSQL: unshardedSchemaSQL,
VSchema: unshardedVSchema,
}

err = clusterInstance.StartUnshardedKeyspace(*uKs, 0, false, clusterInstance.Cell)
if err != nil {
return 1
}

// Start keyspace
sKs := &cluster.Keyspace{
Name: shardedKs,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
create table u_user
(
id bigint,
primary key (id)
) Engine = InnoDB;
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sharded": false,
"tables": {
"u_user": {}
}
}
171 changes: 171 additions & 0 deletions go/test/endtoend/vtgate/queries/vexplain/vexplain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,37 @@ package vexplain

import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"vitess.io/vitess/go/mysql"
"vitess.io/vitess/go/mysql/capabilities"
"vitess.io/vitess/go/sqltypes"
"vitess.io/vitess/go/test/endtoend/cluster"
"vitess.io/vitess/go/test/endtoend/onlineddl"
"vitess.io/vitess/go/test/endtoend/utils"
)

// keyspaceByName returns the started keyspace with the given name. Tests must
// look keyspaces up by name rather than by position in clusterInstance.Keyspaces,
// whose order follows keyspace startup order in TestMain and is not stable (the
// unsharded keyspace starts first, so Keyspaces[0] is not the sharded one).
func keyspaceByName(t *testing.T, name string) *cluster.Keyspace {
t.Helper()
for i := range clusterInstance.Keyspaces {
if clusterInstance.Keyspaces[i].Name == name {
return &clusterInstance.Keyspaces[i]
}
}
require.Failf(t, "keyspace not found", "keyspace %q not found in cluster", name)
return nil
}

func start(t *testing.T) (*mysql.Conn, func()) {
ctx := t.Context()
vtConn, err := mysql.Connect(ctx, &vtParams)
Expand Down Expand Up @@ -149,3 +170,153 @@ func TestVExplainAll(t *testing.T) {
utils.AssertMatchesContains(t, conn, `vexplain /*vt+ EXECUTE_DML_QUERIES */ all insert into user (id,lookup,lookup_unique) values (4,'apa','foo'),(5,'apa','bar'),(6,'monkey','nobar')`, "Insert", "mysql_explain_json")
utils.AssertMatchesContains(t, conn, `vexplain all select id from user where lookup = "apa"`, "mysql_explain_json", "ByDestination")
}

func TestVExplainMySQLPlan(t *testing.T) {
// VEXPLAIN MYSQLPLAN is a v25 syntax; an older vtgate cannot parse it. Under the
// upgrade/downgrade CI this suite can run current test code against an N-1 vtgate.
utils.SkipIfBinaryIsBelowVersion(t, 25, "vtgate")

conn, closer := start(t)
defer closer()

// The result must carry the VTGate plan tree (Route) with real per-shard MySQL
// EXPLAIN output attached, without executing the query.
utils.AssertMatchesContains(t, conn,
`vexplain mysqlplan select id from user where id = 1`,
"mysql_explain_json_by_shard", "Route")

// query_block is a key emitted only by a genuine MySQL EXPLAIN FORMAT=JSON, so it
// proves we actually reached MySQL. MariaDB's EXPLAIN JSON does not use it, so gate
// this assertion to MySQL/Percona 8.0+ (which covers the 8.0 and 8.4 CI flavors).
shardedKeyspace := keyspaceByName(t, shardedKs)
mysqlVersion := onlineddl.GetMySQLVersion(t, shardedKeyspace.Shards[0].PrimaryTablet())
require.NotEmpty(t, mysqlVersion)
atLeast80, err := capabilities.ServerVersionAtLeast(mysqlVersion, 8, 0, 0)
require.NoError(t, err)
if atLeast80 && !strings.Contains(mysqlVersion, "MariaDB") {
utils.AssertMatchesContains(t, conn,
`vexplain mysqlplan select id from user where id = 1`,
"query_block")
}

// A scatter SELECT (no WHERE) fans out to every shard, so MYSQLPLAN must run
// EXPLAIN against each and attach per-shard output keyed by shard name. Assert
// the output carries at least two distinct shard names to prove the fan-out,
// not just a single-shard EXPLAIN. Read the raw cell (real quotes) rather than
// the %v-formatted rows (whose quotes the infra escapes) so the shard-key match
// is unambiguous.
scatter := utils.Exec(t, conn, `vexplain mysqlplan select id from user`)
require.Len(t, scatter.Rows, 1)
scatterOut := scatter.Rows[0][0].ToString()
assert.Contains(t, scatterOut, "mysql_explain_json_by_shard")
distinctShards := 0
for _, shard := range shardedKsShards {
if strings.Contains(scatterOut, fmt.Sprintf("%q", shard)) {
distinctShards++
}
}
assert.GreaterOrEqualf(t, distinctShards, 2,
"expected per-shard EXPLAIN for at least 2 of shards %v, got output:\n%s", shardedKsShards, scatterOut)
if atLeast80 && !strings.Contains(mysqlVersion, "MariaDB") {
assert.Contains(t, scatterOut, "query_block")
}

// DML is not supported (its plans are not Route primitives): it must fail closed
// and point the user to VEXPLAIN ALL, not silently produce a plan with no EXPLAIN.
utils.AssertContainsError(t, conn,
`vexplain mysqlplan insert into user (id,lookup,lookup_unique) values (99,'apa','apa')`,
"use VEXPLAIN ALL instead")

// A lookup vindex cannot resolve shards without executing, so it must fail closed
// and point the user to VEXPLAIN ALL.
utils.AssertContainsError(t, conn,
`vexplain mysqlplan select id from user where lookup_unique = "apa"`,
"use VEXPLAIN ALL instead")
}

// TestVExplainMySQLPlanReservedConn verifies that once a session holds a reserved
// connection (here, by creating a temporary table), VEXPLAIN MYSQLPLAN fails
// closed rather than reporting a plan from a separate connection that cannot see
// the session's temporary tables. The plain SELECT still succeeds on the reserved
// connection, which is exactly the asymmetry the rejection guards against.
func TestVExplainMySQLPlanReservedConn(t *testing.T) {
utils.SkipIfBinaryIsBelowVersion(t, 25, "vtgate")

// A dedicated connection, since creating a temp table pins it to a reserved
// connection for the rest of its life.
ctx := t.Context()
conn, err := mysql.Connect(ctx, &vtParams)
require.NoError(t, err)
defer conn.Close()

// Temporary tables are only allowed on an unsharded keyspace.
utils.Exec(t, conn, "use "+keyspaceByName(t, unshardedKs).Name)
utils.Exec(t, conn, `create temporary table temp_user(id bigint primary key)`)
utils.Exec(t, conn, `insert into temp_user(id) values (1)`)

// The real SELECT works on the reserved connection that can see the temp table.
utils.AssertMatches(t, conn, `select id from temp_user`, `[[INT64(1)]]`)

// But MYSQLPLAN must refuse rather than EXPLAIN on a connection that cannot see
// the temp table. It must not point the user at VEXPLAIN ALL, which shares the
// same standalone-EXPLAIN blind spot.
_, err = utils.ExecAllowError(t, conn, `vexplain mysqlplan select id from temp_user`)
require.ErrorContains(t, err, "reserved connection")
require.NotContains(t, err.Error(), "VEXPLAIN ALL")
}

// TestVExplainMySQLPlanIntoOutfileNoSideEffect verifies that VEXPLAIN MYSQLPLAN of a
// SELECT ... INTO OUTFILE / INTO DUMPFILE returns a plan without error and, crucially,
// writes no file - proving that wrapping the query in EXPLAIN FORMAT=JSON does not
// execute its INTO clause. This is the end-to-end (vtgate -> vttablet -> mysqld)
// counterpart of the standalone MySQL check that motivated leaving INTO OUTFILE
// unguarded: it never runs the wrapped query, so there is no side effect to guard.
func TestVExplainMySQLPlanIntoOutfileNoSideEffect(t *testing.T) {
utils.SkipIfBinaryIsBelowVersion(t, 25, "vtgate")

// The unsharded keyspace has a single tablet, so its mysqld exposes one
// unambiguous secure_file_priv directory that this test - running on the same
// host - can inspect directly.
tablet := keyspaceByName(t, unshardedKs).Shards[0].PrimaryTablet()
res, err := tablet.VttabletProcess.QueryTablet(`select @@secure_file_priv`, unshardedKs, false)
require.NoError(t, err)
secureFilePriv := res.Named().Row().AsString("@@secure_file_priv", "")
if secureFilePriv == "" {
t.Skip("secure_file_priv is empty; INTO OUTFILE is disabled on this mysqld")
}

ctx := t.Context()
conn, err := mysql.Connect(ctx, &vtParams)
require.NoError(t, err)
defer conn.Close()

utils.Exec(t, conn, "use "+unshardedKs)

// A control real SELECT ... INTO OUTFILE must write a file, proving the write
// path works on this mysqld - so a missing file after EXPLAIN below genuinely
// means "not executed", not "OUTFILE is broken here".
controlPath := filepath.Join(secureFilePriv, "vexplain_control.txt")
utils.Exec(t, conn, `select id from u_user into outfile `+sqltypes.EncodeStringSQL(controlPath))
t.Cleanup(func() { _ = os.Remove(controlPath) })
require.FileExists(t, controlPath, "control real SELECT ... INTO OUTFILE did not write a file; the rest of this test is meaningless")

// VEXPLAIN MYSQLPLAN of INTO OUTFILE / INTO DUMPFILE must return a plan and
// write nothing: EXPLAIN FORMAT=JSON never executes the wrapped query.
for _, tc := range []struct {
name string
into string
}{
{"outfile", "into outfile"},
{"dumpfile", "into dumpfile"},
} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(secureFilePriv, "vexplain_explain_"+tc.name+".txt")
t.Cleanup(func() { _ = os.Remove(path) })

utils.AssertMatchesContains(t, conn,
fmt.Sprintf(`vexplain mysqlplan select id from u_user %s %s`, tc.into, sqltypes.EncodeStringSQL(path)),
"mysql_explain_json_by_shard")
require.NoFileExists(t, path, "VEXPLAIN MYSQLPLAN executed the wrapped query: %s was written", path)
})
}
}
2 changes: 2 additions & 0 deletions go/vt/sqlparser/ast_funcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -2136,6 +2136,8 @@ func (ty VExplainType) ToString() string {
return TraceStr
case KeysVExplainType:
return KeysStr
case MySQLVExplainType:
return MySQLStr
default:
return "Unknown VExplainType"
}
Expand Down
2 changes: 2 additions & 0 deletions go/vt/sqlparser/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ const (
PlanStr = "plan"
TraceStr = "trace"
KeysStr = "keys"
MySQLStr = "mysqlplan"

// Lock Types
ReadStr = "read"
Expand Down Expand Up @@ -864,6 +865,7 @@ const (
AllVExplainType
TraceVExplainType
KeysVExplainType
MySQLVExplainType
Comment thread
ejortegau marked this conversation as resolved.
)

// Constant for Enum Type - SelectIntoType
Expand Down
1 change: 1 addition & 0 deletions go/vt/sqlparser/keywords.go
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ var keywords = []keyword{
{"multipoint", MULTIPOINT},
{"multipolygon", MULTIPOLYGON},
{"month", MONTH},
{"mysqlplan", MYSQLPLAN},
{"mysql_errno", MYSQL_ERRNO},
{"name", NAME},
{"names", NAMES},
Expand Down
2 changes: 2 additions & 0 deletions go/vt/sqlparser/parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2802,6 +2802,8 @@ var validSQL = []struct {
input: "vexplain trace select * from t",
}, {
input: "vexplain keys select * from t",
}, {
input: "vexplain mysqlplan select * from t",
}, {
input: "explain analyze select * from t",
}, {
Expand Down
Loading
Loading