GH-50944: [C++] Replace RapidJSON with simdjson in JSON chunker - #50945
GH-50944: [C++] Replace RapidJSON with simdjson in JSON chunker#50945Reranko05 wants to merge 17 commits into
Conversation
e9fc9fe to
be4c2a1
Compare
pitrou
left a comment
There was a problem hiding this comment.
I don't understand why this is parsing JSON by hand?
It seems that we might be able to use simdjson::ondemand::parser::iterate_many.
I initially tried using That said, I agree that parsing JSON manually here is not ideal. I'll revisit this using |
|
@pitrou I tried using Manual structural parsing approach preserved the existing test behavior. @rok, since your earlier implementation was helpful here, do you have any suggestions on how to preserve the current error behavior with |
As long as an error is reported while reading the JSON stream, I don't think we care if it's reported by the chunker or the parser. |
|
@pitrou I gave |
Hmm, I see. Thanks for trying anyway :-)
Well, as a last resort, yes. The problem:
|
|
Try to understand the issue. Is it that json strings legal for rapidjson may fail on simdjson, makes future Arrow release potentially incompatible to old version? Writing our own optimized version looks not ideal. Can we just use simdjson? It's state-of-the-art, and even with self written object delimiter, there's still incompatibility risk I'm afraid. |
|
@cyb70289 I don't think the issue is JSON compatibility between RapidJSON and simdjson. The main issue I ran into is the boundary/streaming semantics. The previous RapidJSON implementation uses With I agree that writing our own optimized JSON parser would not be ideal. The manual approach I used, and which @rok also implemented in rok#47, only scans for the boundary of the first complete object or array while respecting strings and escapes, and then lets simdjson perform the actual JSON validation. But I agree this still introduces complexity and needs careful testing. If there is a way to use simdjson directly while preserving the old stop-after-one-value semantics, that would definitely be preferable. |
Is that a problem? We want to keep compatibility when parsing valid JSON streams. The failure mode for an invalid JSON stream can change. |
|
A discussion about ignoring trailing garbage in simdjson. Looks there're real use cases lenient parsing can be useful. |
| parse_options.newlines_in_values = newlines_in_values | ||
| read_options.block_size = 4 | ||
| with pytest.raises(ValueError, | ||
| match="try to increase block size"): | ||
| with pytest.raises(ValueError): | ||
| self.read_bytes(data, read_options=read_options, | ||
| parse_options=parse_options) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
cpp/src/arrow/json/chunker.cc:134
- FindLast() pre-validates the first non-whitespace character and errors unless it is '{' or '['. That contradicts the later logic that explicitly allows a lone '}' or ']' to appear at the start of a block (as the remainder of a straddled value). As written, a block containing only '}'/']' will be rejected before reaching that special-case handling.
if (block_length > 0) {
const size_t start = ConsumeWhitespace(block);
if (start < block.size() && block[start] != '{' && block[start] != '[') {
return Status::Invalid("JSON parse error: Invalid value");
}
cpp/src/arrow/json/chunker.cc:62
- ConsumeWholeObject() relies on simdjson::ondemand::parser::iterate_many() over the entire buffer. simdjson can reject a stream if there are invalid trailing bytes after an otherwise-complete JSON value, which prevents emitting a valid prefix (this is also reflected by the updated ChunkerTest.Errors expectations). This appears to conflict with the issue/PR goal of preserving previous boundary/error-propagation behavior; consider switching to true structural boundary scanning to find a candidate end position, then validating only that candidate with simdjson so trailing invalid data doesn’t suppress earlier complete records.
simdjson::ondemand::parser parser;
simdjson::ondemand::document_stream stream;
if (parser.iterate_many(input).get(stream) != simdjson::SUCCESS) {
return std::string_view::npos;
python/pyarrow/tests/test_json.py:153
- This test no longer checks the error message when block_size is too small. The newline-delimited path still surfaces the specific "try to increase block size" hint, so it’s useful to keep asserting it for newlines_in_values=False while allowing message variance for newlines_in_values=True.
with pytest.raises(ValueError):
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.
Suppressed comments (1)
python/pyarrow/tests/test_json.py:155
test_block_sizespreviously asserted the failure was specifically the “straddling … (try to increase block size?)” condition. Dropping thematch=makes this test much less specific and could allow unrelated ValueErrors to satisfy it (especially since other tests in this file still validate the exact message). Consider restoring a message match to keep coverage of the intended failure mode.
with pytest.raises(ValueError):
self.read_bytes(data, read_options=read_options,
parse_options=parse_options)
| // simdjson rejects the malformed stream as a whole, so no complete chunk | ||
| // is emitted before the trailing invalid data. | ||
| ASSERT_TRUE(whole); | ||
| ASSERT_EQ(std::string_view(*whole), ""); | ||
| ASSERT_EQ(std::string_view(*rest), parts[0] + parts[1]); |
| simdjson::padded_string input; | ||
|
|
||
| if (partial.empty()) { | ||
| input = simdjson::padded_string(block); | ||
| } else if (block.empty()) { | ||
| input = simdjson::padded_string(partial); | ||
| } else { | ||
| simdjson::padded_string_builder builder(partial.size() + block.size()); | ||
| builder.append(partial); | ||
| builder.append(block); | ||
| input = builder.convert(); | ||
| } |
| } | ||
|
|
||
| while (it != stream.end()) { | ||
| if (!ConsumeDocument(it).ok()) { |
There was a problem hiding this comment.
If ConsumeDocument returns an error, then a document is invalid, right? Why not return with -1 here?
There was a problem hiding this comment.
I also tried returning -1 immediately when ConsumeDocument() fails, but it causes the same streaming error-propagation tests to fail. I will keep the current break behavior.
| if (start < block.size()) { | ||
| const char first_char = block[start]; | ||
|
|
||
| // An incomplete object/array is valid here because it may continue |
There was a problem hiding this comment.
I still don't understand why we're doing this. It will error out in the next chunking call, right?
There was a problem hiding this comment.
I removed this check, but the existing streaming tests failed, so it looks like we need to keep it.
iterate_many() can treat invalid/incomplete data as the next document when no complete document was found. This check prevents clearly invalid data from being carried into the next chunk.
| *out_pos = -1; | ||
| } else { | ||
| consumed_length += ConsumeWhitespace(block); | ||
| // Check the suffix after the last complete document. This is the part |
There was a problem hiding this comment.
Similarly, I don't understand why this is useful.
There was a problem hiding this comment.
Same here, removing this check caused the streaming tests to fail
There was a problem hiding this comment.
Similarly, I don't understand why this is useful.
iterate_many() doesn't give us the chunking behavior we need for trailing data here. This check ensures that non-whitespace data after the last complete document is a valid start of another JSON record, so the error is detected at the current chunk boundary.
There was a problem hiding this comment.
It seems we are having this discussion again and again.
It doesn't matter when exactly the error is detected, as long as an error is detected somehow (either by the JSON chunker, or the JSON parser that runs on the chunks cut out by the JSON chunker).
The JSON chunker is not a public API, it's used to distribute work to the JSON parser(s) running in parallel threads.
Rationale for this change
This PR continues the simdjson migration by replacing the RapidJSON-based JSON boundary detection used by the JSON chunker.
The existing implementation uses RapidJSON's streaming parser to identify complete JSON values. This change replaces that logic with structural boundary detection and simdjson validation.
Changes
simdjson::dom::parser.Fixes: #50944