Skip to content

mysql/json: settle values when parsing, so reading one cannot change it - #20723

Draft
arthurschreiber wants to merge 9 commits into
mainfrom
arthur/json-immutable-values
Draft

mysql/json: settle values when parsing, so reading one cannot change it#20723
arthurschreiber wants to merge 9 commits into
mainfrom
arthur/json-immutable-values

Conversation

@arthurschreiber

@arthurschreiber arthurschreiber commented Jul 28, 2026

Copy link
Copy Markdown
Member

Description

Value.Type() and Value.NumberType() cached their work into the value they were reading. Type() unescaped a string in place and rewrote its kind; NumberType() wrote back the kind it had just worked out. A parsed document is shared by every goroutine running a cached plan, so a read that rewrites what it read is a data race.

Hashing one parsed document from eight goroutines, with -race, over five runs:

                              before   after
["a", "b", "c", "d"]               2       0
[1, 2.5, 3e4, 1844674407370955…]   2       0
2.5                                3       0
"a"                                2       0

The last two race on main as well — WeightString calls both Type() and NumberType() on the value it is given. Containers are clean there only because it reads their length without descending into them. #20718 descends, which turns "a bare JSON literal" into "every value in the tree" and is what made this easy to hit; @GrahamCampbell reported it there, having reproduced torn reads and a wrongly cached result.

Parsing now settles both, so Type() and NumberType() are plain reads and a parsed value never changes again:

  • Strings are unescaped as they are read, the way object keys already were. parseRawValueString mirrors parseRawKey: it reports whether escapes were seen, from the scan it was doing anyway.
  • Numbers have their kind decided from the shape readFloat already saw. A fraction or an exponent answers the question outright; a short run of digits answers it without converting anything. Only integers long enough to straddle the int64 and uint64 limits are converted, and those once rather than once per kind.

Both lazy sentinels, typeRawString and numberTypeRaw, are gone.

A rendering divergence goes with it

MySQL resolves an escape when it parses, so "\u0061" is stored and printed as "a". Vitess printed it verbatim when nothing had called Type(), and unescaped when something had — so the output depended on what the rest of the query happened to touch. It is now always the MySQL form.

Related Issue(s)

Stacked on #20722. Fixes the data race reported on #20718, which should merge after this.

Checklist

  • "Backport to:" labels have been added if this change should be back-ported to release branches
  • If this change is to be back-ported to previous releases, a justification is included in the PR description
  • Tests were added or are not required
  • Did the new or modified tests pass consistently locally and on CI? (locally yes; CI pending)
  • Documentation was added or is not required

Backport justification

Labelled for release-23.0 and release-24.0. This is a data race on state shared by every goroutine running a cached plan, and it is already reachable on those branches: hashing a bare JSON string or number literal reports races today, because WeightString calls both Type() and NumberType() on the value it is handed. What it produced when it went wrong was a torn read and a wrongly cached comparison result, not a clean failure.

The race is narrower there than on main with #20718, which is what made it easy to reproduce — but narrower is not absent, and a data race is not something to leave in a release branch on the grounds that it needs an awkward query to hit.

This cannot be backported on its own. It is stacked on #20722, whose changes to readFloat it builds on directly, so that has to go to the same branches first. #20722 is a behaviour change in its own right — documents carrying a number too large for a double stop parsing — so that decision should be made deliberately rather than inherited from this one.

Also worth weighing for a release branch: the rendering change below is user-visible. A JSON string containing an escape has been printing inconsistently, so some queries will start returning a different — correct, MySQL-matching — string than they do today.

Tests

  • TestNumberKindMatchesParsing is the safety net under deciding a number's kind from its shape. The rule exists to avoid the conversions, so it has to reach the same answer they would: around 270 spellings across every length from 1 to 25 digits, both signs, leading zeros, and the values either side of int64 min, int64 max and uint64 max.
  • TestParseConcurrentReads is the regression test for the races above — one parsed document, eight readers, checking that fingerprints and renders agree.
  • TestParseSettlesValues walks a parsed tree asserting nothing is left undecided, and that reading a value does not change it.
  • TestParseUnescapesStrings pins the rendering against the MySQL form.
  • BenchmarkParse is committed so the cost below stays checkable.

go/mysql/datetime, go/mysql/decimal, go/mysql/json, go/sqltypes, go/vt/sqlparser, go/vt/vtgate/evalengine, the MySQL differential suite and go/vt/vtgate/engine all pass.

Benchmarks

BenchmarkParse, 1024-element documents, arm64, GOMAXPROCS=4, -count=6, via benchstat:

                      │     lazy     │                settled                │
                      │    sec/op    │    sec/op     vs base                │
Parse/integers           9.405µ ± 3%   11.124µ ± 2%   +18.28% (p=0.002 n=6)
Parse/fractions          11.59µ ± 2%    12.32µ ± 2%    +6.25% (p=0.002 n=6)
Parse/exponents          24.50µ ± 3%    25.36µ ± 5%    +3.55% (p=0.004 n=6)
Parse/big_integers       23.21µ ± 2%    43.23µ ± 2%   +86.22% (p=0.002 n=6)
Parse/plain_strings     11.911µ ± 2%    7.284µ ± 3%   -38.84% (p=0.002 n=6)
Parse/escaped_strings    9.684µ ± 1%   27.431µ ± 3%  +183.28% (p=0.002 n=6)
Parse/mixed_object       114.5n ± 3%    121.7n ± 2%    +6.20% (p=0.002 n=6)
geomean                  6.994µ         8.660µ        +23.81%

Documents of plain strings parse faster, because settling the value removes a branch the reader used to take. The two regressions are both work moved earlier rather than work added: an escaped string is unescaped at parse instead of on first read, and a long integer is converted at parse instead of on first read. Each is only waste if the value is never read at all, and the escaped-strings case is a worst case where every one of the 1024 strings carries an escape.

Deciding a long integer's kind cost +255% when it went through the conversions in order; taking the shape into account first, and using a single ParseUint64 for the lengths that straddle the limits, brings that to +86%. I deliberately did not replace that last conversion with digit-string comparisons against the limits — the boundary is worth 20µs to get right by construction.

Deployment Notes

A JSON string containing an escape now renders in its resolved form — "\u0061" prints as "a" — consistently, and matching MySQL. Previously the output depended on whether anything in the query had inspected the value. No migrations or configuration changes.

AI Disclosure

Claude Code wrote this one, including the tests and benchmarks — I reviewed it and provided direction. It came out of review feedback on #20718.

MySQL decides a JSON number's storage when it parses the document: an
integer that fits stays exact and everything else becomes a double. A number
too large for a double therefore has nowhere to live, and MySQL calls the
whole document invalid — JSON_VALID('1e1025') is 0, and inserting it into a
JSON column is refused. Vitess parsed it happily and left every consumer to
cope with a value that should not exist, which is how the same expression
could error in the interpreter and answer 1 in the compiled form.

Underflow is not rejected, matching MySQL: 1e-1000 is a valid document that
reads as zero.

The check is kept off the common path. readFloat already walks the exponent,
so it reports whether one was written, and a number without one has to be
longer than the largest double before it can overflow. Parsing arrays of
integers and of fractional numbers both measure unchanged.

Also sets the number type on the NaN branch, which left whatever the value
cache last held in that field.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
A written sign, a zero-padded exponent and an underflowing one are all
spellings rather than magnitudes, and all stay valid. They go through the
conversion because they carry an exponent, so they are the cases most likely
to be caught by mistake.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 28, 2026 01:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@github-actions github-actions Bot added this to the v25.0.0 milestone Jul 28, 2026
@vitess-bot vitess-bot Bot added NeedsWebsiteDocsUpdate What it says NeedsDescriptionUpdate The description is not clear or comprehensive enough, and needs work NeedsIssue A linked issue is missing for this Pull Request NeedsBackportReason If backport labels have been applied to a PR, a justification is required labels Jul 28, 2026
@vitess-bot

vitess-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Checklist

Hello reviewers! 👋 Please follow this checklist when reviewing this Pull Request.

General

  • Ensure that the Pull Request has a descriptive title.
  • Ensure there is a link to an issue (except for internal cleanup and flaky test fixes), new features should have an RFC that documents use cases and test cases.

Tests

  • Bug fixes should have at least one unit or end-to-end test, enhancement and new features should have a sufficient number of tests.

Documentation

  • Apply the release notes (needs details) label if users need to know about this change.
  • New features should be documented.
  • There should be some code comments as to why things are implemented the way they are.
  • There should be a comment at the top of each new or modified test to explain what the test does.

New flags

  • Is this flag really necessary?
  • Flag names must be clear and intuitive, use dashes (-), and have a clear help text.

If a workflow is added or modified:

  • Each item in Jobs should be named in order to mark it as required.
  • If the workflow needs to be marked as required, the maintainer team must be notified.

Backward compatibility

  • Protobuf changes should be wire-compatible.
  • Changes to _vt tables and RPCs need to be backward compatible.
  • RPC changes should be compatible with vitess-operator
  • If a flag is removed, then it should also be removed from vitess-operator and arewefastyet, if used there.
  • vtctl command output order should be stable and awk-able.

@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.71038% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 72.88%. Comparing base (70c7a72) to head (2180b6f).
⚠️ Report is 509 commits behind head on main.

Files with missing lines Patch % Lines
go/mysql/json/parser.go 90.65% 17 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##             main   #20723       +/-   ##
===========================================
+ Coverage   69.67%   72.88%    +3.21%     
===========================================
  Files        1614        6     -1608     
  Lines      216793     1704   -215089     
===========================================
- Hits       151044     1242   -149802     
+ Misses      65749      462    -65287     
Flag Coverage Δ
partial 72.88% <90.71%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@arthurschreiber arthurschreiber added Backport to: release-23.0 Needs to be backport to release-23.0 Backport to: release-24.0 Needs to be backport to release-24.0 and removed NeedsDescriptionUpdate The description is not clear or comprehensive enough, and needs work NeedsWebsiteDocsUpdate What it says NeedsIssue A linked issue is missing for this Pull Request NeedsBackportReason If backport labels have been applied to a PR, a justification is required labels Jul 28, 2026
A JSON number carries two ways of being too big, and only one of them shows
up after conversion. MySQL bounds the exponent a number is written with
before it converts anything: the decimal point can travel 308 places, plus
one for every digit the number already has after it. 0e309 is refused on
those grounds even though it is zero, while 0.00e310 is fine because its two
fraction digits buy the places back.

Converting caught the second way and missed the first, so every exponent that
lands on zero came through: 0e309, 0.0e310, 0.00e311. Converting is still
needed for the other direction — 10e308 is written within the bound and
overflows anyway.

readFloat already walks both the fraction digits and the exponent, so it
reports them rather than reporting that an exponent was there at all. A
written exponent stops at a ceiling that clears the largest double plus every
digit the number could hold after its point, so a long one neither overflows
nor changes the answer. Negative exponents are left alone, matching MySQL:
underflow is a valid document that reads as zero.

Parsing arrays of integers, of fractional numbers and of numbers with
exponents all measure unchanged.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
The number reader was a general-purpose one: it took a written plus, an
integer part opening with a zero, and a decimal point with nothing on one
side of it. JSON allows none of those and MySQL rejects all of them, so
Vitess answered questions MySQL refuses to answer — JSON_EXTRACT('{"a": .2}',
'$.a') is 0.2 here and an error there. Which one a query gets depends on
whether it is evaluated in vtgate or pushed down, so the same query fails in
one place and succeeds in the other. A number written 007 also kept its
leading zeros all the way back out again.

Reading the grammar directly turns out to be less work than the loop that
read around it: the leading-zero bookkeeping and the labelled switch go away,
and parsing an array of integers or of fractional numbers is around a fifth
to a quarter faster.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
The parser carried a branch for nan, from the JSON reader it grew out of. It
is an extension to JSON rather than part of it, and MySQL does not take it:
JSON_VALID('nan') is 0. A document Vitess accepted here was one no MySQL
column could hold, and 'nan' as a JSON bind value reached the evalengine as a
number no consumer could read — the interpreter reports a decimal range error
where the compiled form compares two of them equal.

Deleting the branch also removes the number type it had to set, which was
whatever the value cache last left there.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
arthurschreiber and others added 3 commits July 28, 2026 08:15
Whether a JSON number is too big is not a question about the number, it turns
out — it is a question about how the number was written. MySQL parses JSON
with RapidJSON, which splits the digits between a significand it accumulates
into a double and a power of ten to scale that by, and the split decides which
way the last place rounds. 1.7976931348623158e308 does not fit. The same value
written 1.79769313486231580e308 does, because the extra digit moves the split.
Comparing a correctly converted value against the largest double cannot tell
those two apart, so it took the first one as well.

So the conversion is now RapidJSON's rather than a correct one, deliberately
landing an ULP or two off the true value so the boundary lands where MySQL's
does. The written-exponent bound moves into it as well, since that is where
RapidJSON applies it, leaving one place that decides whether a document holds
a number instead of two that had to agree.

Checked against MySQL 8.0.45, 8.4.11 and 9.4.0 over 1279 documents — both
sides of the boundary at every spelling above, the exponent bound stepped one
place at a time, and 900 random numbers, a fifth of them crowded up against
the largest double. All three versions agree with each other and with this,
and 8.4 still parses with the same flags and the same code.

The check runs only for numbers whose digits could reach that far, as before,
and reaching the answer in one pass rather than converting the whole number
makes those about a third cheaper. Numbers that cannot reach it are untouched.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
The conversion accumulates the significand into a double one digit at a
time, multiplying and adding. MySQL's builds round the two operations
separately, but written as d*10 + digit the Go compiler is free to fuse
them into one FMA on arm64, which rounds once and can land the
accumulation an ULP from where MySQL puts it — enough to flip which
side of the largest double a number falls on. MySQL 8.0.45, 8.4.11 and
9.4.0 all accept 17976931348623154547712857878e280; on arm64 the fused
loop rejected it. The explicit float64 conversion forces the
intermediate rounding that the spec otherwise lets the compiler
discard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Type() and NumberType() cached their work into the value they were reading:
Type() unescaped a string in place and rewrote its kind, NumberType() wrote
back the kind it had just worked out. A parsed document is shared by every
goroutine running a cached plan, so a read that rewrites what it read is a
data race. Hashing a shared literal from several goroutines reports races on
both, and produced torn reads and a wrong cached answer.

Parsing now settles both. Strings are unescaped as they are read, the way
object keys already were: parseRawValueString reports whether escapes were
seen, from the scan it was doing anyway. A number's kind is decided from the
shape readFloat already saw, so a fraction or an exponent answers the
question outright and a short run of digits answers it without converting
anything; only integers long enough to straddle the limits are converted, and
those once rather than once per kind. Both lazy sentinels are gone, and
Type() and NumberType() are plain reads.

This also settles what a string renders as. MySQL resolves an escape when it
parses, so a unicode escape prints as the character it names; Vitess printed
it verbatim if nothing had called Type() and unescaped if something had, which
made the output depend on what the rest of the query happened to touch.

Parsing a document of strings without escapes gets faster. Escaped strings
pay the unescape up front rather than on first read, and long integers pay a
conversion they used to defer; BenchmarkParse covers both.

Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 28, 2026 10:16
@arthurschreiber
arthurschreiber force-pushed the arthur/json-immutable-values branch from 6da804d to 770f8c7 Compare July 28, 2026 10:16

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Settling a number's kind at parse time left parseNumberType with no
production caller. The one reader left is the test that checks the
shape rule against the conversions, where it serves as the oracle, so
it lives with that test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signed-off-by: Arthur Schreiber <arthur@planetscale.com>
Copilot AI review requested due to automatic review settings July 28, 2026 10:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Comment thread go/mysql/json/parser.go
@@ -1195,10 +1257,6 @@ func (v *Value) Type() Type {
if v == nil {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should update this concurrency contract now that parsing settles the value completely. The cached-plan use case in this PR deliberately shares one parsed value between concurrent readers, so saying that Value cannot be used concurrently seems to contradict the behavior being established.

Perhaps something like:

// Value may be read concurrently once parsing is complete.
// Concurrent mutation remains unsafe.

The equivalent warning on Object should have the same read-only distinction.

Base automatically changed from arthur/json-reject-oversized-numbers to main July 28, 2026 22:31
@github-actions

Copy link
Copy Markdown
Contributor

This PR is being marked as stale because it has been open for 30 days with no activity. To rectify, you may do any of the following:

  • Push additional commits to the associated branch.
  • Remove the stale label.
  • Add a comment indicating why it is not stale.

If no action is taken within 7 days, this PR will be closed.

@github-actions github-actions Bot added the Stale Marks PRs as stale after a period of inactivity, which are then closed after a grace period. label Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backport to: release-23.0 Needs to be backport to release-23.0 Backport to: release-24.0 Needs to be backport to release-24.0 Component: Evalengine changes to the evaluation engine Component: Query Serving Stale Marks PRs as stale after a period of inactivity, which are then closed after a grace period. Type: Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants