feat(common-json): extract pluggable JsonTokenizer SPI#2001
Open
jfallows wants to merge 5 commits into
Open
Conversation
976d339 to
0f041f1
Compare
Promote JsonTokenizer to an exported interface with a MemorySegment-based scan entry point, add JsonTokenizerFactory (ServiceLoader SPI with priority()/available() selection), and rename the concrete implementation to JsonTokenizerImpl. JsonParserImpl now obtains its tokenizer through JsonTokenizerFactory.instantiate() instead of constructing it directly, and feeds the tokenizer a wrapped MemorySegment/offset/limit rather than an InputStream. Behavior is unchanged: full common-json suite passes (4845 tests), and model-json/binding-mcp build and test cleanly against the change with no call-site updates required.
…urns it Introduces JsonStringView (extends CharSequence, adds getSegment(): DirectBufferEx) as the declared return type of JsonTokenizer.stringView(), replacing the plain CharSequence it returned before. getSegment() is a nullable hint: the contiguous run of original source bytes a value's characters were decoded from, when such a run exists and corresponds 1:1 to the decoded content -- null when decoding transformed the bytes (an escape sequence, a folded multi-byte UTF-8 sequence) and so there is no single source byte range left to point at. A caller that can exploit a direct byte-range shortcut (a generator splicing an unmodified value straight through instead of re-encoding it) treats this as opportunistic, falling back to ordinary CharSequence access whenever it is null. JsonTokenizerImpl always decodes into scratch (a StringBuilder), so it never has a byte-backed alternative to offer -- its stringView() wraps whatever CharSequence it already returns (scratch mid-decode, or the materialized String once taken) in a small reused DecodedStringView flyweight whose getSegment() unconditionally returns null. This is a correct, honest answer for this tokenizer, not a placeholder: nothing about this change requires or assumes a segment-backed implementation exists anywhere yet. JsonParserImpl needed no changes. getStringView()'s existing branch for verbatim/segment values already returns tokenizer.stringView() directly, unwrapped -- so a tokenizer that does produce a real segment-backed JsonStringView (e.g. a vectorized implementation with a zero-copy verbatim capture path) will have that type flow through to callers automatically, with no changes needed here. The other branch (decoded char-scalars, tracking bounded/resumable output-write progress via stringViewOffset) is unaffected: its own StringView wrapper deliberately stays plain CharSequence, since decoded content has no segment to offer regardless of what wraps it -- a caller checking instanceof JsonStringView there should, correctly, get false. Added JsonTokenizerStringViewTest covering key, escaped-string, and number stringView() calls, confirming getSegment() is null in every case for this tokenizer. Full existing suite (4835 tests) passes unchanged; checkstyle, license, and moditect module packaging all clean. This is prerequisite, tokenizer-agnostic groundwork for a vectorized JsonTokenizer implementation (zilla-plus) to expose its own verbatim captures as real, segment-backed JsonStringView instances, letting downstream consumers (JsonGeneratorImpl and beyond) opportunistically splice unmodified values by byte range instead of decoding and re-encoding through CharSequence. No such producer or consumer exists yet in this repo -- this change only establishes the shared vocabulary both sides will need.
…directly Completes the JsonStringView groundwork from the previous commit, which established the type and interface plumbing but did not yet wire up a consumer. JsonGeneratorImpl's write(CharSequence) and write(CharSequence, Completion) now check instanceof JsonStringView: when getSegment() is non-null, its bytes splice directly into string-body output via a bulk buffer copy instead of the codepoint-by-codepoint escape path -- safe unconditionally, since a non-null segment is only ever returned for content that never needed escaping in the first place (documented as a hard invariant on JsonStringView.getSegment() in this commit: plain ASCII only, one byte per char, guaranteed). The bounded write(CharSequence, Completion) path only takes the fast path when the whole segment fits the current output window (checked against the same budget the ordinary path already reserves the closing quote from); a segment too large for one call declines and falls through unchanged to the existing codepoint path, which already knows how to bound and correctly resume a partial write. This deliberately does not attempt to fast-path a multi-call resumed write of an oversized segment-backed value -- that would require reasoning about how a future JsonTokenizer producer re-exposes "the remainder" of a segment across calls, which is that producer's contract to keep, not this generator's to assume. The fast path here only ever claims "this exact instance, handed to me on this exact call, either fits or it doesn't" -- nothing about cross-call resumption. Added JsonGeneratorStringViewTest with a minimal fixture JsonStringView (no production tokenizer in this repo produces a real segment-backed one yet) covering: the fast path firing and producing correct output for both the unbounded and bounded write paths, correct fallback when getSegment() is null, and correct decline-and-fallback (producing byte-identical output and consumed() count to the plain-String codepoint path) when a segment doesn't fit the bounded output window. Full suite (4839 tests) passes; checkstyle, license, and moditect module packaging all clean.
…ds terminal() into last param Replaces wrap(MemorySegment, offset, limit) + the separate terminal(boolean) mutator with wrap(DirectBufferEx, offset, limit) and wrap(DirectBufferEx, offset, limit, boolean last), mirroring JsonParserEx's own two-overload shape one layer up. JsonTokenizerImpl reads bytes via DirectBufferEx.getByte(), which already applies wrapAdjustment internally, so JsonParserImpl's wrap() call sites no longer need to unwrap the raw backing segment and manually add the adjustment before calling into the tokenizer. The three-argument overload still only rebinds the window and leaves terminal state untouched, exactly matching prior behavior (JsonParserImpl's own three-argument wrap() never called terminal() either); only the four-argument overload sets it, atomically with the rebind.
Adds JsonTokenizer.numberIntegral()/numberInLongRange()/numberLongValue(), accumulating sign and magnitude one leading-integer-part digit at a time alongside the existing RFC 8259 number-grammar state machine (JsonTokenizerImpl.validateNumberChar), instead of JsonParserImpl.getInt()/ getLong() re-scanning the value's CharSequence a second time through Integer.parseInt/Long.parseLong. numberMagnitude only accumulates up to 18 digits (LONG_SAFE_DIGITS) -- at or under that many digits a signed long cannot overflow regardless of sign, so no per-digit overflow check is needed. A longer integer (rare; both Long.MIN_VALUE and Long.MAX_VALUE are 19 digits) reports numberInLongRange() false and getInt()/getLong() fall back to the original CharSequence-based path unchanged, so the fast path only ever has to be correct for the case it claims to handle -- everything else, including all NumberFormatException- throwing misuse, keeps its existing behavior exactly. isIntegralNumber() similarly reads numberState directly (ZERO/INT are the only accepting states never reached via a DOT/EXP transition) instead of re-scanning the CharSequence for '.'/'e'/'E'. JsonNumberDecodeBM (new): isolates getLong() from the rest of the parse over a 2000-element number array. Before/after (stashing the fused implementation and re-running the identical benchmark): 5288 -> 5622 ops/s (+6.3%), gc.alloc.rate.norm unchanged at ~1.3 B/op (Long.parseLong(CharSequence,...) was already non-allocating; the win is skipping its virtual charAt() calls and redundant validity re-checking of digits the tokenizer's own grammar already validated). JsonTokenizerNumberValueTest (new): direct tokenizer-level coverage of the three new methods, including the 18-vs-19-digit fast-path boundary. JsonParserTest: two new getLong() tests pin that boundary and -0 handling at the JsonParserImpl level. Full suite (4851 tests), checkstyle, and license all pass unchanged.
92bf7ba to
89e677c
Compare
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.
Description
Promotes
JsonTokenizer(common-json) from a single hard-coded internal class to an exported, pluggable SPI:JsonTokenizeris now an exported interface with aDirectBufferEx-based scan entry point (wrap(buffer, offset, limit)/wrap(buffer, offset, limit, boolean last)+ parameterlessadvance()) instead of the previousInputStream-onlyadvance(InputStream).window(int)remains a separate method, decoupled fromwrap().JsonTokenizerFactorySPI (ServiceLoader-based) withpriority()/available()capability-probe selection and a staticinstantiate()loader.internal.JsonTokenizeris renamed toJsonTokenizerImpl, registered via a newJsonTokenizerImplFactoryandMETA-INF/services/module-infouses/provides.JsonParserImplobtains its tokenizer throughJsonTokenizerFactory.instantiate()and feeds it its own wrappedDirectBufferExdirectly, rather than anInputStream. The existingInputStream-constructor compatibility path (including external re-wraps of aDirectBufferInputStreamEx-backed input betweenhasNext()calls) is preserved unchanged via arewrapRawInputIfNeeded()helper — no eager reads, no checked-exception surface change.JsonStringView(extendsCharSequence, addsgetSegment(): DirectBufferEx) is now the declared return type ofJsonTokenizer.stringView(), andJsonGeneratorImplopportunistically splices its bytes directly when writing — see below.No external call sites change: neither
model-jsonnorbinding-mcptouchJsonTokenizerdirectly, both go throughJsonParserEx/JsonPipeline.Motivation and context
Decouples
JsonParserImplfrom a single hard-coded tokenizer implementation, allowing alternateJsonTokenizerproviders to be plugged in viaServiceLoaderand selected by priority with a capability probe (available()), while preserving default behavior with no available alternate provider.Fixes # (none — see zilla-plus#861 for the motivating downstream use case)
JsonStringView: tokenizer-agnostic groundwork for zero-copy value splicingJsonTokenizer.stringView()now declaresJsonStringView(not plainCharSequence) as its return type.JsonStringViewadds one method beyondCharSequence:getSegment(): DirectBufferEx, a nullable hint — the contiguous run of original source bytes a value's characters were decoded from, when such a run exists and corresponds 1:1 to the decoded content, ornullwhen decoding transformed the bytes (an escape sequence, a folded multi-byte UTF-8 sequence) and there is no single source byte range left to point at. When non-null, every byte in the segment is guaranteed plain ASCII and never requires escaping — a hard invariant documented on the interface, since it's what lets a consumer splice the bytes directly with no re-encoding pass, and lets the segment's byte length be relied on as equal to the view's char length.JsonTokenizerImplalways decodes intoscratch(aStringBuilder), so it never has a byte-backed alternative to offer — itsstringView()wraps whateverCharSequenceit already returns in a small reusedDecodedStringViewflyweight whosegetSegment()unconditionally returnsnull. This is a correct, honest answer for this tokenizer, not a placeholder.JsonParserImplneeded no changes.getStringView()'s existing branch for verbatim/segment values already returnstokenizer.stringView()directly, unwrapped, so a tokenizer that does produce a real segment-backedJsonStringViewwill have that type flow through to callers automatically. The other branch (decoded char-scalars, tracking bounded/resumable output-write progress viastringViewOffset) is unaffected: its ownStringViewwrapper deliberately stays plainCharSequence, since decoded content has no segment to offer regardless of what wraps it.JsonGeneratorImplnow consumes this, in bothwrite(CharSequence)(unbounded) andwrite(CharSequence, Completion)(bounded): aninstanceof JsonStringViewcheck with a non-nullgetSegment()splices the segment's bytes directly via a bulk buffer copy instead of the codepoint-by-codepoint escape path — safe unconditionally per the ASCII/no-escaping invariant above. The bounded path only takes this shortcut when the whole segment fits the current output window; a segment too large for one call declines and falls through unchanged to the existing codepoint path, which already knows how to bound and resume a partial write correctly. This deliberately does not attempt to fast-path a multi-call resumed write of an oversized segment-backed value — that would require reasoning about how a futureJsonTokenizerproducer re-exposes "the remainder" of a segment across calls, which is that producer's contract to keep, not this generator's to assume; the fast path here only ever claims "this exact instance, on this exact call, either fits or it doesn't."This is deliberately narrow, tokenizer-agnostic groundwork, not a full feature: no production
JsonTokenizerimplementation in this repo produces a real segment-backedJsonStringViewyet (JsonGeneratorStringViewTestuses a minimal test fixture to verify the generator side of the contract independently). It establishes the shared vocabulary a future vectorizedJsonTokenizerprovider (zilla-plus) would need to plug into and get this benefit automatically, with no further changes on this side. A larger, related migration ofcommon-json's exported buffer surface fromDirectBufferExtoMemorySegmentwas considered and explicitly deferred untilMemorySegment/asSlice()gets Project Valhalla's value-class optimization, so it can be done together with proper performance validation rather than piecemeal; thisJsonStringViewaddition does not depend on or block that.wrap()takesDirectBufferExinstead ofMemorySegment;terminal()folds intowrap()The scan entry point originally introduced in this PR took a raw
MemorySegmentplus a separateterminal(boolean)mutator. Both are replaced:wrap(DirectBufferEx buffer, int offset, int limit)/wrap(DirectBufferEx buffer, int offset, int limit, boolean last)— mirroringJsonParserEx's own two-overload shape one layer up, instead of a bespoke tokenizer-only contract.terminal(boolean)mutator is removed;lastis now a parameter of the four-argumentwrap()overload, set atomically with the window rebind. The three-argument overload only rebinds the window and leaves terminal state untouched — this matches the prior behavior exactly, sinceJsonParserImpl's own three-argumentwrap()never calledterminal()either.This was previously forcing every caller of
JsonTokenizer.wrap()to first unwrap aDirectBufferExdown to its raw backingMemorySegmentand manually addwrapAdjustment()before calling in —JsonParserImplhad two such call sites doing that arithmetic redundantly.JsonTokenizerImplnow reads bytes viaDirectBufferEx.getByte(index), which already applieswrapAdjustment()internally, so both call sites simply pass theirDirectBufferExstraight through. NewJsonTokenizerWrapTestlocks in the two-overload contract directly againstJsonTokenizerImpl: a four-argumentwrap(..., true)completes a bare trailing number at EOF, a four-argumentwrap(..., false)correctly starves instead, and a subsequent three-argumentwrap()call leaves a previously-set terminal state unchanged across areset().Fused integral number decode:
numberIntegral()/numberInLongRange()/numberLongValue()Adds three methods to
JsonTokenizer, lettingJsonParserImpl.getInt()/getLong()skip a secondCharSequence-based pass throughInteger.parseInt/Long.parseLongfor the common case of a plain integral value.JsonTokenizerImpl.validateNumberChar— the existing RFC 8259 number-grammar state machine, already advanced one char at a time — now also accumulates sign and magnitude for the leading integer-part digits alongside its existing state transitions, so the value is available for free once the number completes.numberMagnitudeonly accumulates up to 18 digits (LONG_SAFE_DIGITS): at or under that many digits a signedlongcannot overflow regardless of sign, so no per-digit overflow check is needed. A longer integer (rare — bothLong.MIN_VALUEandLong.MAX_VALUEare 19 digits) reportsnumberInLongRange()false, andgetInt()/getLong()fall back to the originalCharSequence-based path unchanged — the fast path only ever has to be correct for the case it claims to handle; every other case (non-integral values, out-of-range values, all existingNumberFormatException-throwing misuse) keeps its exact prior behavior via that fallback.isIntegralNumber()similarly readsnumberStatedirectly (ZERO/INTare the only accepting states never reached via aDOT/EXPtransition) instead of re-scanning theCharSequencefor./e/E.New
JsonNumberDecodeBMisolatesgetLong()from the rest of the parse over a 2000-element number array. Before/after (stashing the fused implementation and re-running the identical benchmark): 5,288 → 5,622 ops/s (+6.3%),gc.alloc.rate.normunchanged at ~1.3 B/op (Long.parseLong(CharSequence, ...)was already non-allocating; the win is skipping its virtualcharAt()calls and redundant validity re-checking of digits the tokenizer's own grammar already validated). NewJsonTokenizerNumberValueTestpins the three methods directly at the tokenizer level, including the 18-vs-19-digit fast-path boundary; two newJsonParserTestcases pin that same boundary and-0handling at theJsonParserImpllevel.This is the interface-level piece zilla-plus#902 called out as needing a cross-repo design before attempting (its own vectorized digit-extent scan,
scanDigitRun, currently only counts digits and never touches their values) — implemented here first since it's SIMD-agnostic; the SIMD provider adopting it is separate follow-up work in that repo.Test plan
./mvnw install -pl runtime/common-json: checkstyle, license, and full test suite pass — 4851 tests, 0 failures, 0 errors.JsonTokenizerFactoryTest(priority ordering, availability filtering, throws-when-none-available, realServiceLoaderdiscovery of the built-in factory), updatedJsonTokenizerPathTest(migrated to theDirectBufferEx-based entry point),JsonTokenizerStringViewTest(key/escaped-string/numberstringView()calls all correctly report anullgetSegment()for this tokenizer),JsonGeneratorStringViewTest(the generator's fast path fires and produces correct output when a segment fits, both unbounded and bounded; correctly falls back when the segment isnullor too large for the current output window, producing byte-identical output to the plain-Stringpath in that case),JsonTokenizerWrapTest(thewrap()/lastcontract described above), andJsonTokenizerNumberValueTest/two newJsonParserTestcases (the fused number-decode contract described above).JsonPipelineBMafter theDirectBufferEx.getByte()swap shows no allocation regression (near-zerogc.alloc.rate.normon the pipeline paths, unchanged ~968 B/op on the schema-validation paths that were already allocating for unrelated reasons). NewJsonNumberDecodeBMmeasures the number-decode fusion specifically (see above).model-jsonandbinding-mcpbuilt and their test suites run against the changedcommon-jsonwith no regressions and no call-site changes required../mvnw clean install -pl runtime/common-json) verified clean.Generated by Claude Code