Repository navigation
Conversation
unflatten() decides in a first pass which prefixes of the keys denote arrays
(those that have a reference token 0 among their children), and looks up
prefixes again in the second pass. A prefix was identified by hashing it, and
by comparing it byte by byte with the entry in the set, from its beginning.
A key with n tokens therefore cost n^2/2 bytes: 100,000 tokens "/0" took
2.4 s (3.12.0: 5 ms), and so did "/0" followed by "/1" tokens, because every
new level looks its prefix up. Keys in the order of an unsorted object type
(ordered_json) defeated the shortcut for keys that share a beginning with the
previous one: four keys were enough to be quadratic.
Prefixes of less than 64 bytes are kept in a hash table and compared as
before, which costs a bounded number of bytes. Longer prefixes are kept in a
tree whose nodes are looked up by their parent and the text of one token. The
node of a prefix is found by walking the tokens of the key from the root, so
all prefixes of a key together cost one pass over the key, and no prefix is
compared as a whole. The nodes along the key walked last are kept, so keys
that share a beginning do not walk it again. The array/object decision and
the independence from the order of the keys are unchanged.
The hash function is seeded with a value derived from the address of a local
variable, which address space layout randomization changes between
processes, so keys cannot be crafted to collide in advance: with the fixed
constants, 20,000 keys of 16 + 2 bytes with the same hash took 0.5 s (random
keys, and 3.12.0: 7 ms). Where addresses are not randomized, or when the seed
is known, k short prefixes can still collide and cost k^2 / 2 comparisons of
fewer than 64 bytes. Results do not depend on the seed.
Measurements (Apple clang -O2, arm64), unflatten() of one key with n tokens:
tokens "/0" n=25k n=50k n=100k n=200k
before 175 ms 617 ms 2437 ms 9729 ms
after 4 ms 6 ms 12 ms 27 ms
3.12.0 1 ms 3 ms 5 ms 10 ms
"/0" then "/1" n=25k n=50k n=100k n=200k
before 153 ms 603 ms 2436 ms 9678 ms
after 1 ms 3 ms 5 ms 10 ms
3.12.0 1 ms 3 ms 5 ms 11 ms
ordered_json, 4 keys /0*m/a0, /10, /0*m/a1, /11
m=25k m=50k m=100k m=200k
before 397 ms 1402 ms 5191 ms 20723 ms
after 4 ms 7 ms 15 ms 33 ms
3.12.0 2 ms 3 ms 8 ms 17 ms
Ordinary documents (flatten() then unflatten(), instructions retired): within
0.3 % of before for canada, citm_catalog, twitter, jeopardy, mesh,
update-center and random from the benchmark corpus.
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
uintptr_t is already uint64_t on LP64 Linux, so the explicit cast is useless there; rely on the implicit (widening) conversion instead. Signed-off-by: Niels Lohmann <mail@nlohmann.me>
…use-auto Signed-off-by: Niels Lohmann <mail@nlohmann.me>
With an object type that keeps the insertion order (ordered_json), a key
such as "/a" can come after "/a/b". The final assignment then replaced the
object built for "/a/b" without an error, so {"/a/b":2,"/a":1} gave
{"a":1}. json never saw this because std::map visits the shorter key first.
Throw type_error.313 if the target of the final assignment is no longer
null, as json does for the same members. A null value keeps the container,
so {"/a/b":2,"/a":null} gives {"a":{"b":2}} for both object types.
Fixes #6105.
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
unflatten()takes quadratic time in the number of reference tokens of one key on currentdevelop(3.13.0 work in progress; 3.12.0 is linear). An object with one key of 100,000/0tokens (200 KB) takes 2.4 s; 3.12.0 needs 5 ms. The time quadruples with each doubling of the key. An attacker who controls one key of an object passed tounflatten()can make the process busy for minutes with a few megabytes of input.unflatten.mdpromises "Linear in the size of the JSON value".Cause
3.13.0 decides in a first pass which prefixes of the keys denote arrays (those that have a reference token
0among their children), so that the result no longer depends on the order of the keys, and looks the prefixes up again in the second pass. A prefix was identified by hashing it and by comparing it byte by byte with the set entry, from its beginning, so a key with n tokens cost n²/2 bytes. Forordered_json(and any unsorted object type) the shortcut for keys that share a beginning with the previous key does not help: four keys were enough to be quadratic.Change
detail::json_pointerchanged.Measurements (Apple clang -O2, arm64)
unflatten()of one key with n tokens/0:ordered_json, 4 keys (/0×m/a0,/10,/0×m/a1,/11): before 397 / 1402 / 5191 / 20723 ms for m = 25k / 50k / 100k / 200k, after 4 / 7 / 15 / 33 ms, 3.12.0 2 / 3 / 8 / 17 ms.Ordinary documents (
flatten()thenunflatten(), instructions retired): within 0.3 % of before for canada, citm_catalog, twitter, jeopardy, mesh, update-center and random. A realistic deep document (100 levels, 100k leaves): +1.5 % instructions, +5-12 % wall time vsdevelop, and about 9× faster than 3.12.0.Known trade-off: two thousand different long keys (2000 keys × 2000 unique tokens, 12 MB of keys) take 1.4 s instead of 3.1 s on
develop, but still about 6× longer than 3.12.0 (0.2 s) and use about 300 MiB more memory for the tree (about 78 bytes per token). 3.13.0 needs a set entry per prefix for the order independence anyway; path compression of unique tails would reduce this further and is left for a follow-up.Tests
unit-json_pointer.cpp: semantics of the array/object decision (prefixes around the 64-byte hand-over, different token splits of one prefix, escapes, tokens longer than a word), a random round trip in shuffled key order withordered_json, and a time-ratio check (best of 3 CPU-time runs, the same keys with an array-hitting token vs. tokena; seven shapes including the 4-keyordered_jsonshape; ratio about 3 now, 25 to 300 on the previous code, bound 6 or 10). The test fails ondevelopand on the intermediate version that still had theordered_jsonproblem. It was run 50× 8 in parallel and 64× 16 in parallel at -O0 and -O1 without a failure.Differential tests against
develop(about 150,000 randomized flattened objects,jsonand shuffledordered_json, withJSON_DIAGNOSTICS) gave identical results and error messages. Allocation failure was injected at every allocation of oneunflatten()call: no leaks. The suite passes under ASan and UBSan; clang-tidy 22, clang-Weverythingand g++ 16 report nothing new.Also: a key that is a prefix of an earlier key (#6105)
This PR also fixes #6105, which is in the same function. With an object type that keeps the insertion order (
ordered_json), a key such as/acan come after/a/b. The final assignment then replaced the object built for/a/bwithout an error:{"/a/b":2,"/a":1}gave{"a":1},{"/a/0/b":1,"/a/0":2}gave{"a":[2]}and{"/x":1,"":2}gave2.jsonnever sees this, becausestd::mapvisits the shorter key first and the walk of the longer key throwstype_error.313. 3.12.0 has the same bug.unflatten()now throwstype_error.313when the target of the final assignment is no longer null, so the result no longer depends on the key order. Anullvalue keeps the container, as it does forjsonwhen the shorter key comes first:{"/a/b":2,"/a":null}gives{"a":{"b":2}}for both object types. This case is not exotic, becauseflatten()turns empty objects and arrays intonull.The branch is not reached for
jsonor any sorted object type.continueskips only the assignment, so the cached prefix nodes stay valid. Tests inunit-json_pointer.cppcover the three inputs above, the reverse order, and thenullcases forjsonandordered_json; 5 of the new assertions fail without the change.unflatten.mddocuments the newtype_error.313case and has a version-history note.Fixes #6105.
Breaking changes
No breaking changes to the API. The speed-up changes only private helpers. With an object type that keeps the insertion order, an input with a key that is a prefix of an earlier key now throws the documented
type_error.313instead of silently dropping data (bug fix, #6105);jsonbehaves as before.🤖 Generated with Claude Code