[ruby/json] Avoid re-decoding an incomplete number on every ResumableParser chunk

`JSON::ResumableParser` re-decodes an incomplete number on every chunk it
receives, so a long number delivered in small chunks is disproportionately slow
to parse -- the work is quadratic (a bit worse in practice) in the number's
length.

## Background

When the parser reads a number that reaches the end of the buffer, it cannot
tell whether more digits will arrive in a later chunk, so it rewinds the cursor
and returns `false` to wait for more input. Until now it fully decoded the
number before that check -- and then discarded the result. For a long run of
digits that decode builds a bignum, whose cost grows faster than linearly with
the digit count, and it was repeated on every chunk that extended the number.

The total cost is therefore quadratic (a bit worse in practice) in the length
of the number. The same "rewind and re-process" happens for incomplete strings
and comments, but those only re-scan (no value construction), so only numbers
pay this decode cost.

## Benchmark

Feeding an incomplete token in 128-byte chunks, one iteration being one full
chunked feed (`benchmark-ips`; macOS 15.6 arm64; ruby 4.0.0):

```ruby
require "json"
require "benchmark/ips"

def feed_incomplete(token, chunk_size)
  parser = JSON::ResumableParser.new
  i = 0
  while i < token.bytesize
    parser << token.byteslice(i, chunk_size)
    i += chunk_size
    parser.parse
  end
end

CHUNK = 128
sizes = [100, 16_000, 32_000, 64_000, 128_000]

Benchmark.ips do |x|
  x.config(time: 3, warmup: 1)
  sizes.each do |n|
    number = "1" * n
    string = '"' + ("a" * n)
    x.report("number #{n} digits") { feed_incomplete(number, CHUNK) }
    x.report("string #{n} chars")  { feed_incomplete(string, CHUNK) }
  end
end
```

Per-iteration time, before vs. after this change:

| Input (128-byte chunks) | Before   | After    | Change          |
|-------------------------|---------:|---------:|-----------------|
| number, 100 digits      | 949 ns   | 414 ns   | negligible      |
| number, 16,000 digits   | 16.4 ms  | 0.138 ms | ~119x faster    |
| number, 32,000 digits   | 94.8 ms  | 0.531 ms | ~178x faster    |
| number, 64,000 digits   | 540.8 ms | 2.05 ms  | ~264x faster    |
| number, 128,000 digits  | 3.07 s   | 8.13 ms  | ~378x faster    |
| string, 100 chars       | 411 ns   | 391 ns   | ~1x (unchanged) |
| string, 16,000 chars    | 49.4 μs  | 47.7 μs  | ~1x (unchanged) |
| string, 32,000 chars    | 169.7 μs | 165.8 μs | ~1x (unchanged) |
| string, 64,000 chars    | 621 μs   | 616 μs   | ~1x (unchanged) |
| string, 128,000 chars   | 2.38 ms  | 2.35 ms  | ~1x (unchanged) |

The string rows are unchanged, confirming the change is number-specific.

## Fix

Defer the decode. `json_parse_number` still scans the digits (cheap, and enough
to advance the cursor so the caller can detect an incomplete number), but when
the scan reaches the end of the buffer in resumable mode it returns `Qundef`
without decoding. The caller already rewinds and returns `false` in exactly that
case, so the decoded value was never used there. The number is decoded once,
when a following byte proves it complete.

Non-resumable `JSON.parse` is unaffected: it passes `resumable = false`, so the
early return is never taken and the number is decoded as before. A test checks
that large integers and floats split across feeds still decode to the correct
value.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PpeQSEFkF1X1Uuzjx9BsYe

https://github.com/ruby/json/commit/d1299d42ac
This commit is contained in:
Masataka Pocke KuwabaraandClaude Opus 4.8 authored and git committed 2026-07-09 09:43:03 +00:00
1 parent df0622320b
commit d65b0ec0ef
2 files changed
+30 -3

No files matched your search

+13 -3
View File
@@ -1457,7 +1457,7 @@ static inline int json_parse_digits(JSON_ParserState *state, uint64_t *accumulat
return (int)(state->cursor - start);
}
static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig *config, bool negative, const char *start)
static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig *config, bool negative, const char *start, bool resumable)
{
bool integer = true;
const char first_digit = *state->cursor;
@@ -1514,6 +1514,16 @@ static inline VALUE json_parse_number(JSON_ParserState *state, JSON_ParserConfig
}
}
// A number touching the end of the buffer may still grow in a later chunk,
// so the caller will rewind and wait. Decoding it now would build a value
// -- for a long run of digits, an expensive bignum -- only to discard it,
// and repeating that on every resumed chunk is quadratic in the number's
// length. The digit scan above already advanced the cursor, which is all
// the caller needs to detect the incomplete number.
if (RB_UNLIKELY(resumable && eos(state))) {
return Qundef;
}
if (integer) {
return json_decode_integer(mantissa, mantissa_digits, negative, start, state->cursor);
}
@@ -1638,7 +1648,7 @@ ALWAYS_INLINE(static) bool json_parse_any(JSON_ParserState *state, JSON_ParserCo
case '-': {
state->cursor++;
value = json_parse_number(state, config, true, value_start);
value = json_parse_number(state, config, true, value_start, resumable);
if (RB_UNLIKELY(UNDEF_P(value) && config->allow_nan && peek(state) == 'I')) {
state->cursor = value_start;
@@ -1661,7 +1671,7 @@ ALWAYS_INLINE(static) bool json_parse_any(JSON_ParserState *state, JSON_ParserCo
}
case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': {
value = json_parse_number(state, config, false, value_start);
value = json_parse_number(state, config, false, value_start, resumable);
// Top level numbers are ambiguous when parsing streams, we can't
// know if we parsed all the digits if we hit EOS.
+17
View File
@@ -175,6 +175,23 @@ class JSONResumageParserTest < Test::Unit::TestCase
assert_resumed_parsing('123 ', trailing_bytes: 1)
end
def test_large_numbers_split_across_feeds_are_decoded_correctly
{
'12345678901234567890123456789012345678901234567890 ' => 12345678901234567890123456789012345678901234567890,
'-98765432109876543210987654321 ' => -98765432109876543210987654321,
'3.14159265358979323846264338327950288 ' => 3.14159265358979323846264338327950288,
'-1.5e-300 ' => -1.5e-300,
}.each do |doc, expected|
parser = new_parser
value = nil
doc.each_char do |char|
parser << char
value = parser.value if parser.parse
end
assert_equal expected, value, doc.inspect
end
end
def test_nul_byte_is_a_syntax_error
# A NUL byte in a structural position must raise, not stall forever waiting for more input
# (peek() returns 0 both at EOS and for a literal NUL byte).