Repository navigation
Add support for parsing chunked data #983
Description
This is similar to #854 but a different feature request. Both of #854 and this are for migrating to the json gem from yajl-ruby (in Fluentd. #854's description doesn't mention it but it's mentioned in a comment #854 (comment) ) but this proposes different API. We can't migrate from yajl-ruby with API proposed in #854.
#854 proposed parser.parse(io) like API but what we need is io.each_chunk {|chunk| parser.feed(chunk)} like API. An application wants to read data not the json gem read data. parser.parse(io) like API reads data by the json gem. Fluentd can't migrate to yajl-ruby with the API.
Fluentd that is a event based log collector reads data from multiple sockets efficiently by IO multiplexing. We may not be able to read a whole JSON (e.g. ["a", 2, true]) at a time with the IO multiplexing. We may get a whole JSON as multiple chunks (e.g. ["a", , 2, and true]). yajl-ruby can parse chunks without concatenating all of them:
yajl-ruby:
require "yajl"
parser = Yajl::Parser.new
parser.on_parse_complete = lambda {|x| pp x}
parser << "[\"a\""
parser << ", 2, "
parser << "true]" # lambda {|x| pp x} is called with ["a", 2, true]The json gem:
require "json"
JSON.parse(["[\"a\"", ", 2, ", "true]"].join)If an application can extract a whole JSON from an input without parsing input (*), we don't need yajl-ruby style API. But an input may have multiple JSONs (like Fluentd's use case), we need yajl-ruby style API. Because we need to parse JSON to split data in an input to JSONs for this case.
(*) For example:
- Only one JSON exists in an input
- Each JSON is delimited by new line (JSON lines)
- ...
API
JSON::Parser.new requires a source. So it may be better that we define a new class such as JSON::StreamParser instead of reusing existing JSON::Parser.
JSON::StreamParser accepts a chunk not a whole JSON:
parser = JSON::StreamParser.new
parser.parse_chunk("[\"a\"")
parser.parse_chunk(", 2, ")
parser.parse_chunk("true]")Other candidate for parse_chunk: consume
We need to define callback style API to pass parsed data to application. Because one parse_chunk call may parse 0 or more JSONs. For example, if chunk is [1][2], [1] and [2] are parsed at once.
JSON::Parser accepts on_load callback. We may resuse the name for JSON::StreamParser but on_load is for JSON value not JSON. It may be better that we use different name. For example:
parser = JSON::StreamParser.new
parser.on_load = lambda {|x| pp x}
parser.parse_chunk("[\"a\"")
parser.parse_chunk(", 2, ")
parser.parse_chunk("true]") # lambda {x| pp x} is called with ["a", 2, true]
parser.parse_chunk("[1][2]") # lambda {x| pp x} is called twice with [1] and [2]
Activity
byroot commentedon May 28, 2026
Thanks, I better understand the ask now (the streaming term is used for different things in JSON parsing context).
I'm open to such feature, as I think it can be useful, however I suspect it would require a fairly large refactoring of the parser.
Right now the parser use recursive C function calls, so it can't really be interrupted or resumed.
So the parser would first need to be changed quite a bit to look more like
msgpack's parser: https://github.com/msgpack/msgpack-ruby/blob/677e6b888f1512550d9b3cdb4b5aca28f9fdb859/ext/msgpack/unpacker.c, which is something I considered in the past, but didn't act on.The best approach is likely to keep a dedicated stack to represent the parser state. (e.g.
.stack = { ARRAY, ARRAY, OBJECT, OBJECT_KEY... }).And that's assuming such change wouldn't degrade performance too much, if it does, then we'd need to consider whether we have to maintain two parsers so that the classic usage keep performing well.
As for the API, I think I'd prefer something that doesn't use callback, like MessagePack:
But perhaps exceptions aren't ideal. The problem is we can't just return
nilgiven thatnullis a valid document in some contexts. So perhaps something like:Also this is just for the C parser, which I think can reasonably be converted to this new architecture. The JRuby parser still uses
rageland would be significantly more work. But I'd be OK with only having this feature in the C extension until someone offer to make significant changes to the Jave version.eregon commentedon May 28, 2026
This sounds likely to degrade performance significantly to me and result in a much more complicated parser.
Maybe one approach could be to basically:
That would also require the input to be given in chunks that terminate, would that be enough?
If not maybe JSON could have a mode where it accepts extra trailing bytes, and give a way to know how many bytes it parsed so we can restart parsing after that.
That would be needed for the case of
buf = "[1][2]".eregon commentedon May 28, 2026
Another thought is if this is strict JSON with only native JSON types a Regexp (using Subexpression Calls:
\g) might be enough to:byroot commentedon May 28, 2026
Not necessarily. It's an idea I toyed with for a while, but ultimately it just replace direct calls with a stack based dispatch, not that different from a bytecode interpreter.
Whether it can be as fast is hard to be certain until it's implemented, and it's a significant amount of work so...
My worry would be more about the mere concept of a significant rewrite, as it's potential for new bugs and such.
As for your suggestion, it may be fine if you only occasionally have partial documents, but from my limited understading of fluentd, it's a very common case, so having to restart parsing from 0 every time would be prohibitive I fear.
headius commentedon May 29, 2026
I originally proposed the streaming dumping and I agree this would be good to add.
kou commentedon May 31, 2026
It's suboptimal. It parses one JSON multiple times. If all use cases don't require performance, it's acceptable. But Fluentd is a log collector and log collector needs performance because it needs to process many logs.
eregon commentedon May 31, 2026
Using that analogy the current version has no dispatch, like JITed Ruby code, and this would have dispatch overhead, like a bytecode interpreter. So that seems pretty clear there will be some overhead. Also it means the CPU can't predict the next function to be called, it will be just a function pointer I guess and that's well known to be slower.
Maybe it's worth asking the reverse question, what are the problems with yajl? It seems purpose-built for this, so seems a good fit, no? Most usages of JSON don't need this, but supporting it would slow parsing down and add significant complexity.
kou commentedon May 31, 2026
yajl-ruby may not be maintained actively. See also: https://github.com/brianmario/yajl-ruby
byroot commentedon May 31, 2026
Yeah, yajl has maintenance concerns.
I suspect it would be negligible with the proper optimizations (direct threading & al), especially since from experience the dispatch really isn't where the most time is spent.
But regardless, if it's indeed a perf regression, it's always possible to have two parsers that would share a lot of code.
Indeed, which is my biggest hesitation to be honest. So far my stance has been that I wanted to support the 95% use case, and not grow the API too much, leaving more specialized use cases to alternative gems.
Something I wonder, and sorry if it's obvious because I know very little about fluentd, is why documents can't be separated by a newline or some other marker? Are the JSON blobs purely black boxes and you can't assume they're on a single line? Not trying to say you are holding it wrong, mostly just curious.
kou commentedon Jun 1, 2026
Because Fluentd's Forward Protocol Specification v1 https://github.com/fluent/fluentd/wiki/Forward-Protocol-Specification-v1 didn't define it... There are tools that implement this specification. So Fluentd can't assume that JSONs are separated by something to keep backward compatibility.
kddnewton commentedon Jun 1, 2026
I have a JSON parser that doesn't use recursion here: https://gist.github.com/kddnewton/3e90d0dceda633641933e98bd4ee8694. The original commit was what I pushed up for the JSON rewrite, I don't mind porting the second commit here so we can compare perf.
headius commentedon Jun 1, 2026
No concerns with chunked parser from the JRuby side. Dispatches from Java back into Ruby will soon be inlinable and should not impact performance.
A rewrite for JRuby may be coming soon.
kddnewton commentedon Jun 1, 2026
This starts the work: #989 in case you're interested. Feel free to close otherwise.
enebo commentedon Jun 2, 2026
I will be submitting a PR for a new JRuby JSON parser this week. At worst, a new parser will need to be added for streaming but it will use 95+% of the same logic and just need to do a little extra tracking and it might not because the new parser uses a stack like @byroot mentions above with state like:
in a stack:
So at a fail it knows if
stateis at OBJECT_COMMA, then it is expecting a ','. So the next entry intoparsemight just continue where it left off. Anyways, just pointing out I do not think this will be a big problem with the new parser.byroot commentedon Jun 4, 2026
Alright, big thanks to @kddnewton for #989, after a few tweaks (mostly adding gotos) it's on par with the previous recursive implementation (sometimes show up to 3-4% slower, but it's within the variance, and honestly I'm OK to take a small hit to no longer be limited by the C stack).
That however is just the first step. The parser is now iterative, but there's still some substantial work to do to make it resumable.
JSON::StreamParseror something, not yet sold on the name)fal<EOS>right now raisesunexpected token 'fal'far<EOS>should directly raise a syntax error.16 remaining items
byroot commentedon Jun 16, 2026
All those edge cases are unit tests, except for the parser recovery. Does YAJL has a way to recover?
Watson1978 commentedon Jun 16, 2026
Thanks.
No,
yajl-rubydoes not have a built-in way to recover the same parser instance.Once it encounters an invalid JSON chunk, its internal state machine halts. To recover from a
Yajl::ParseErrorin our old implementation, we had to discard the dead parser and instantiate a completely newYajl::Parserobject to continue parsing the rest of the stream.But, if we can use
JSON::ResumableParser#clear, it allows us to gracefully drop the malformed buffer and reset the state without needing to reallocate the parser instance.I think it is useful.
byroot commentedon Jun 16, 2026
I see.
.clearwill be a bit cheaper, and will keep the various allocated capacities, but the difference should be minor.Watson1978 commentedon Jun 18, 2026
Performance Comparison:
yajl-rubyvsJSON::ResumableParserI conducted a performance test to evaluate the efficiency of
JSON::ResumableParsercompared to the currentyajl-rubyimplementation.Test Scenario
Results (Handling ~10 MB/s constant load)
yajl-rubyJSON::ResumableParserRef. fluent/fluentd#5383 (comment)
byroot commentedon Jun 18, 2026
Wow, 14 times less CPU usage seem too good to be true.
Watson1978 commentedon Jun 18, 2026
byroot commentedon Jun 18, 2026
Yes, not saying I don't believe you, I'm just always a bit careful when I get huge difference like this, as I got bit by it a few time in the past.
On my mac/M3 the difference is 17x:
I'm still a bit impressed, because
Yajl.parse(not the chunked version) while old is still quite competitive withJSON.parse, and since chunked parsing was the whole point of Yajl, I would have expected it to be relatively well optimized.Watson1978 commentedon Jun 18, 2026
Yeah, the difference is definitely more pronounced on Apple Silicon. I tested it across two different architectures (Intel Core Ultra 9 on Linux vs. Apple M1 Max on macOS) to see why, and the results are quite interesting.
Looking at the absolute numbers, it seems the massive 15-17x gap on Mac isn't because
ResumableParsergets a special boost on ARM, but rather because yajl suffers a significant performance penalty on it.Linux
macOS
byroot commentedon Jun 23, 2026
👋 @kou and @Watson1978 how the testing going? Let me know when you're done and I'll happily cut a new release.
Watson1978 commentedon Jun 23, 2026
Hi @byroot! 👋
A huge thanks for your works!
We ran heavy load testing with Fluentd's
in_forwardplugin and it works perfectly now!No more crashes, and the memory profile looks great.
We also did a thorough audit on GC safety and exception handling during streaming, and the C-extension looks incredibly solid.
I think you can release it anytime.
Thanks!
byroot commentedon Jun 23, 2026
Great to hear.
json 2.20.0is released.Watson1978 commentedon Jun 23, 2026
Thanks!
kou commentedon Jun 25, 2026
Sorry for my late work.
I have only one minor feature request: #1041
Others work well. Great!
byroot commentedon Jul 13, 2026
Just in case,
json 2.21.0is out with the numerous fixes and improvements toResumableParseryou submitted.Watson1978 commentedon Jul 13, 2026
Thanks