Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 14 additions & 0 deletions changelog/25.0/25.0.0/summary.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,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)
- [Cross-shard `JSON_ARRAYAGG` and `JSON_OBJECTAGG`](#vtgate-cross-shard-json-aggregation)
- **[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 @@ -282,6 +283,19 @@ 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-cross-shard-json-aggregation"/>Cross-shard `JSON_ARRAYAGG` and `JSON_OBJECTAGG`</a>

VTGate now plans `JSON_ARRAYAGG` and `JSON_OBJECTAGG` for scatter queries and cross-shard `GROUP BY`. The aggregation functions are executed by MySQL on each shard and VTGate merges the per-shard JSON documents (array concatenation for `JSON_ARRAYAGG`; object union with MySQL's "last duplicate key wins" rule for `JSON_OBJECTAGG`). Previously these queries failed with `VT12001: unsupported: in scatter query: aggregation function`.

Notes:

- MySQL documents `JSON_ARRAYAGG` element order and `JSON_OBJECTAGG` duplicate-key resolution as dependent on row order, which is not guaranteed. In a sharded keyspace the merge order across shards is likewise not guaranteed, so results can interleave differently than on an unsharded MySQL, and may vary between executions for queries with duplicate object keys across shards.
- The top-level members of a `JSON_OBJECTAGG` result merged at VTGate are serialized in MySQL's normalized key order (key length first, then byte order). Nested objects inside member values can serialize their members in a different byte order (never a different meaning) than MySQL when member keys have unequal lengths — the same behavior as the existing `JSON_OBJECT()` evalengine function. More generally, any VTGate-evaluated expression over a merged aggregate (for example `JSON_EXTRACT`) re-serializes the whole document, emitting object keys in lexicographic rather than MySQL's length-then-bytes order and text-parsed non-integer numbers through float formatting; raw aggregate output is unaffected.
- Ordinary comparisons (`=`, `<=>`, `!=`, `<`, `<=`, `>`, `>=`), `NULLIF`, `IN`, `NOT IN`, `BETWEEN`, `NOT BETWEEN` and simple `CASE` over these aggregates are supported: VTGate's expression engine matches MySQL's semantics for them, including MySQL's multi-operand comparison-domain coercion for JSON (a string comparand is treated as a JSON string scalar, not parsed as a JSON document — compare against `CAST('...' AS JSON)` to compare documents). Operations that consume the document's text serialization — `LIKE`, `REGEXP`, the `REGEXP_*` functions, `JSON_UNQUOTE`, `CAST`/`CONVERT` to a character type and the `LENGTH` family — are supported only over array aggregates of scalar, non-lossy values, whose text VTGate reproduces byte-for-byte; over a merged document that can contain an object, or whose values lost a scalar subtype in transit, they are rejected (`VT12001`) because VTGate would serialize the document differently than MySQL. `GREATEST` and `LEAST` are rejected (`VT12001`) conservatively: MySQL evaluates them with warning 1235 and result metadata VTGate does not yet reproduce. Unrelated expressions next to such an aggregate are not restricted; only operands that actually depend on the merge are.
- MySQL's internal JSON representation can retain binary, bit, decimal, and temporal scalar subtypes that are erased when shard JSON partials cross the query protocol as text. Raw aggregate output over such values remains correct and supported, but any VTGate-level operation that would compare such an aggregate is rejected (`VT12001`) to avoid returning values that differ from a single MySQL server: expression comparisons (including `IN`, `BETWEEN` and simple `CASE`), `GROUP BY`, `DISTINCT`, `ORDER BY`, join comparisons and values a join or correlated subquery would push back into a shard query as a bind variable, VTGate-level `MIN`/`MAX`/`COUNT(DISTINCT)`/`SUM(DISTINCT)`, and scalar or `IN`/`NOT IN` subquery results consumed by an outer query. A value argument whose type is unknown to the planner is treated the same way. An explicit text conversion (`JSON_UNQUOTE`, `CAST(... AS CHAR)`) is itself rejected over such an aggregate — the conversion re-serializes the document with VTGate's divergent bytes — so it is not a workaround; these conversions stay supported over scalar, non-lossy array aggregates.
- When the planner cannot prove whether a compared value derives from a VTGate-merged JSON aggregate (an unmapped plan shape in a query that contains such a merge), planning fails closed with a `VT12001` error rather than assuming the value is independent.
- Aggregating these functions over joins that cannot be merged into a single route, or over constructs that cannot be pushed down to MySQL (such as `LIMIT` below the aggregation), now fails with `VT12001: unsupported: aggregation function '<expr>' must be pushed down to MySQL` (previously the generic scatter-aggregation error above).

### <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
57 changes: 57 additions & 0 deletions go/mysql/json/clone.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
Copyright 2026 The Vitess Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package json

import (
"fmt"
"strings"
)

// Clone returns a deep copy of v: arrays and objects are copied recursively,
// so in-place updates on one value never affect the other. Leaf payloads are
// shared, except raw strings not yet unescaped: Type rewrites their backing
// bytes in place, so they are copied. The lazily cached number type needs no
// copy, since NumberType writes it to a field each clone owns; a value cloned
// while other goroutines can reach it must have had its number types resolved
// first (ResolveNumberTypes), since Clone reads the field that NumberType's
// lazy classification writes.
func (v *Value) Clone() *Value {
switch v.t {
case TypeObject:
kvs := make([]kv, len(v.o.kvs))
for i, item := range v.o.kvs {
kvs[i] = kv{k: item.k, v: item.v.Clone()}
}
return &Value{o: Object{kvs: kvs}, t: TypeObject}
case TypeArray:
a := make([]*Value, len(v.a))
for i, item := range v.a {
a[i] = item.Clone()
}
return &Value{a: a, t: TypeArray}
case TypeBoolean, TypeNull:
// ValueTrue, ValueFalse and ValueNull are immutable singletons
// whose pointer identity is meaningful.
return v
case typeRawString:
return &Value{s: strings.Clone(v.s), t: typeRawString}
case TypeString, TypeNumber, TypeDate, TypeTime, TypeDateTime, TypeOpaque, TypeBit, TypeBlob:
return &Value{s: v.s, t: v.t, n: v.n}
default:
panic(fmt.Errorf("BUG: unexpected Value type: %d", v.t))
}
}
122 changes: 122 additions & 0 deletions go/mysql/json/clone_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
Copyright 2026 The Vitess Authors.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package json

import (
"testing"

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

func TestCloneSingletons(t *testing.T) {
// The boolean and null singletons are compared by pointer identity,
// so Clone must preserve them.
assert.Same(t, ValueTrue, ValueTrue.Clone())
assert.Same(t, ValueFalse, ValueFalse.Clone())
assert.Same(t, ValueNull, ValueNull.Clone())
}

func TestCloneMutationIndependence(t *testing.T) {
t.Run("mutating the clone does not affect the original", func(t *testing.T) {
original := MustParse(`{"a": [1, 2, 3], "o": {"x": [4, 5]}, "s": "keep"}`)
want := original.String()

clone := original.Clone()
require.Equal(t, want, clone.String())

obj, ok := clone.Object()
require.True(t, ok)
obj.Get("a").DelArrayItem(0)
obj.Get("a").SetArrayItem(1, MustParse(`42`), Set)
nested, ok := obj.Get("o").Object()
require.True(t, ok)
nested.Del("x")
nested.Set("y", MustParse(`"new"`), Set)
obj.Del("s")

assert.Equal(t, `{"a": [2, 42], "o": {"y": "new"}}`, clone.String())
assert.Equal(t, want, original.String())
})

t.Run("mutating the original does not affect the clone", func(t *testing.T) {
original := MustParse(`{"a": [1, 2, 3], "o": {"x": [4, 5]}}`)
clone := original.Clone()
want := clone.String()

obj, ok := original.Object()
require.True(t, ok)
obj.Get("a").DelArrayItem(2)
obj.Set("b", MustParse(`[6]`), Set)
nested, ok := obj.Get("o").Object()
require.True(t, ok)
nested.Set("x", MustParse(`7`), Replace)

assert.Equal(t, `{"a": [1, 2], "b": [6], "o": {"x": 7}}`, original.String())
assert.Equal(t, want, clone.String())
})
}

func TestCloneKindPreservation(t *testing.T) {
values := []*Value{
NewNumber("1.5", NumberTypeDecimal),
NewNumber("18446744073709551615", NumberTypeUnsigned),
NewNumber("-42", NumberTypeSigned),
NewNumber("1.5e10", NumberTypeFloat),
NewString("foo"),
NewBlob("\x00\x01"),
NewBit("\x81"),
NewDate("2023-10-12"),
NewTime("14:35:02"),
NewDateTime("2023-10-12 14:35:02"),
NewOpaqueValue("opaque"),
ValueTrue,
ValueFalse,
ValueNull,
}
original := NewArray(values)
clone := original.Clone()

cloned, ok := clone.Array()
require.True(t, ok)
require.Len(t, cloned, len(values))
for i, v := range values {
assert.Equal(t, v.Type(), cloned[i].Type())
assert.Equal(t, v.NumberType(), cloned[i].NumberType())
assert.Equal(t, v.Raw(), cloned[i].Raw())
}
}

func TestCloneRawStrings(t *testing.T) {
// Type lazily unescapes raw strings by rewriting their backing bytes in
// place; the clone must own its bytes so that unescaping one value
// cannot corrupt the other.
original := MustParse(`["fo\no"]`)
clone := original.Clone()

cloned, ok := clone.Array()
require.True(t, ok)
s, ok := cloned[0].StringBytes()
require.True(t, ok)
assert.Equal(t, "fo\no", string(s))

arr, ok := original.Array()
require.True(t, ok)
s, ok = arr[0].StringBytes()
require.True(t, ok)
assert.Equal(t, "fo\no", string(s))
}
21 changes: 21 additions & 0 deletions go/mysql/json/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,27 @@ func parseNumberType(ns string) NumberType {
return NumberTypeUnknown
}

// ResolveNumberTypes classifies every number in the document, in place.
// Parsed numbers are classified lazily: the first NumberType call writes the
// classification back to the value. A document that will be shared between
// goroutines — such as a constant folded into a cached plan — must resolve
// its number types first, while a single goroutine still owns it, so that
// concurrent readers never race that write.
func (v *Value) ResolveNumberTypes() {
switch v.t {
case TypeObject:
for _, item := range v.o.kvs {
item.v.ResolveNumberTypes()
}
case TypeArray:
for _, item := range v.a {
item.ResolveNumberTypes()
}
case TypeNumber:
v.NumberType()
}
}

func (v *Value) Int64() (int64, bool) {
i, err := fastparse.ParseInt64(v.s, 10)
if err != nil {
Expand Down
51 changes: 51 additions & 0 deletions go/mysql/json/parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"strings"
"testing"

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

"vitess.io/vitess/go/hack"
Expand Down Expand Up @@ -810,3 +811,53 @@ func TestMarshalToBlob(t *testing.T) {
require.Equal(t, `{"k": `+encoded+`}`, string(NewObject(obj).MarshalTo(nil)))
})
}

// TestResolveNumberTypes verifies that resolving a parsed document classifies
// every nested number in place, so a later NumberType call never has to write,
// and that explicitly constructed numbers keep their declared type.
func TestResolveNumberTypes(t *testing.T) {
t.Run("parsed", func(t *testing.T) {
v := MustParse(`{"i": -1, "u": 18446744073709551615, "f": 1.5e300, "a": [7, {"n": 2.5}], "s": "no number"}`)

var rawNumbers func(v *Value) int
rawNumbers = func(v *Value) int {
switch v.t {
case TypeObject:
var raw int
for _, item := range v.o.kvs {
raw += rawNumbers(item.v)
}
return raw
case TypeArray:
var raw int
for _, item := range v.a {
raw += rawNumbers(item)
}
return raw
case TypeNumber:
if v.n == numberTypeRaw {
return 1
}
return 0
default:
return 0
}
}
require.Equal(t, 5, rawNumbers(v), "parsed numbers must start out lazily classified")

v.ResolveNumberTypes()
assert.Zero(t, rawNumbers(v))

obj, ok := v.Object()
require.True(t, ok)
assert.Equal(t, NumberTypeSigned, obj.Get("i").NumberType())
assert.Equal(t, NumberTypeUnsigned, obj.Get("u").NumberType())
assert.Equal(t, NumberTypeFloat, obj.Get("f").NumberType())
})

t.Run("constructed", func(t *testing.T) {
v := NewArray([]*Value{NewNumber("1.5", NumberTypeDecimal)})
v.ResolveNumberTypes()
assert.Equal(t, NumberTypeDecimal, v.a[0].NumberType())
})
}
Loading
Loading