Skip to content

Add streaming early-exit JsonPath extractor for simple linear paths#18972

Open
xiangfu0 wants to merge 3 commits into
apache:masterfrom
xiangfu0:claude/streaming-json-path-extractor-19a1e2
Open

Add streaming early-exit JsonPath extractor for simple linear paths#18972
xiangfu0 wants to merge 3 commits into
apache:masterfrom
xiangfu0:claude/streaming-json-path-extractor-19a1e2

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Summary

Ingestion and query-time JsonPath extraction goes through Jayway
(parse(json).read(path)), which builds a full Jackson DOM of the document and only then walks to
the field — once per row per derived column, a major ingestion CPU cost. This adds a streaming
Jackson-based extractor for simple linear paths ($ followed only by .key, ['key'],
[int]), used by both re-parse call sites:

  • JsonFunctions.jsonPath (and jsonPathString/Long/Double/Array/Exists, which route through it)
  • JsonExtractScalarTransformFunction (ingestion transformConfigs)

Everything else falls back to Jayway: wildcards, deep scan (..), filters, unions, slices,
negative indices, non-String/byte[] input, and any document whose first non-whitespace character
is not { or [ (bare scalars, null, empty, whitespace, BOM, plain text).

Feature flags — both default OFF

Key Effect
pinot.jsonpath.fastpath.enabled Gate the streaming fast path.
pinot.jsonpath.fastpath.earlyExit.enabled Also stop at the leaf instead of scanning the whole root value. No effect unless the above is on.

Pure kill-switch back to Jayway. Server-local compute only — no wire format, segment format, or
config-schema change, so mixed-version safe with no migration.

Semantics

The default (full-scan) path returns the same value as Jayway as Pinot configures it
(JacksonJsonProvider + JacksonMappingProvider + SUPPRESS_EXCEPTIONS) for every input, including
duplicate-key last-wins and raising InvalidJsonException on a document malformed anywhere inside its
root value. Content after the root value is ignored, exactly as ObjectMapper.readValue ignores it.

One deliberate difference. Jayway builds a DOM of the whole document, so it materializes and
validates values the path never addresses. This extractor skips those subtrees, so a value Jackson can
lex but not materialize makes Jayway reject the document while this returns the requested value.
Exactly two such values exist, and only when they sit outside the addressed path:

  1. a string longer than StreamReadConstraints.maxStringLength (20,000,000 chars);
  2. under useBigDecimal (which backs jsonExtractScalar(..., 'STRING') / 'BIG_DECIMAL'), a float
    literal whose exponent overflows an int, e.g. 1e999999999999.

When the offending value is the addressed one, both raise, so the results still agree. This is
inherent to the optimization — matching Jayway here would mean materializing every string in every
document, which is precisely the cost this change exists to avoid. The extractor never returns a
different value for the addressed path; it only declines to fail on values the caller did not ask
about, and it is the safer of the two (it never allocates the 20 MB string or the pathological
BigDecimal). Pinned by a test so it cannot widen unnoticed.

Early exit trades two behaviors for speed when the field appears early, since the tail is never
read: (1) duplicate keys resolve to the first occurrence; (2) a document malformed strictly after
the addressed field no longer raises. Both inputs are RFC-8259-undefined or corrupt, and the switch
is off by default.

Correctness gate

StreamingJsonPathExtractorTest is a differential test with the production Jayway fallback as the
oracle
: the same jsonPath* entry points run flag-off vs flag-on and must agree, over a
hand-picked matrix (missing paths, invalid JSON, null/empty/whitespace/BOM, type mismatches, nested
arrays, arrays of objects, deep nesting, unicode/surrogates, escaped/dotted/duplicate keys,
big/precise numbers) plus 60k randomized fuzz iterations, the byte[] overloads (incl. multibyte
and invalid-UTF-8), and the USE_BIG_DECIMAL_FOR_FLOATS context. Early-exit divergences are
asserted explicitly. JsonFunctionsFastPathTest and JsonExtractScalarTransformFunctionFastPathTest
re-run the existing corpora through the fast path.

Performance

JMH (BenchmarkJsonPathExtraction, Apple M, ~688-byte nested payload, 4×5s warmup / 6×5s measure);
the fastPathEnabled=false arm is the current Jayway baseline, measured in the same run:

jsonPathString, one column ops/s vs Jayway
Jayway (today) 458k 1.0x
Streaming, exact parity (full scan) 836k 1.8x
Streaming, early exit, field early 5.09M 11.1x
Streaming, early exit, field last 813k 1.8x
4 derived columns from one document ops/s vs Jayway
Jayway (4 parses) 106k 1.0x
Streaming, one pass, all 4 paths 763k 7.2x

1.8x is the Jackson lexing floor: exact parity requires reading the whole root value, so a bare
nextToken()-to-EOF loop (~907 ns) is already close to the full extract (~919 ns). The larger wins
come from early exit (undefined-input tradeoff) and from single-pass multi-path extraction, which
must walk the document anyway and so gets exact parity for free — the right target for
transformConfigs pulling N columns from one source.

On representative production logs

To validate on non-synthetic data, both the correctness and the throughput were re-measured on a
corpus of 50k Vector log lines (~1.4KB nested JSON each), in two variants: message as a
stringified-JSON blob, and message as a nested object (2% of object-variant rows carry a plain
non-JSON string message). message is the last top-level field, so $.message.* is a worst-case
late extraction. The transform extracts a realistic set of 12 columns (6 top-level dimensions plus
6 fields under message).

Correctness: 100k lines × 15 paths = 1.5M extractions compared against Jayway, 0 differences
(full-scan mode). Real production JSON — duplicate-free, deeply nested, mixed string/object
message, unicode — is a stronger differential corpus than the randomized fuzzer.

Throughput (JMH, ops/s; string / object variant):

Scenario Jayway Streaming Speedup
1 column, late ($.message.status), full scan 357k / 396k 553k / 537k 1.55x / 1.36x
1 column, late, object, early exit 396k 725k 1.83x
jsonPathString end-to-end (fast path off → on) 356k / 372k 555k / 517k 1.56x / 1.39x
12 columns, one doc — single pass 31.5k / 32.9k 486k / 444k 15.5x / 13.5x

Single-column is a modest ~1.4–1.6x here (vs 1.8x on the smaller synthetic payload): these docs are
larger and the target fields sit at the end, so full-scan parity reads almost the whole document —
the lex floor. The multi-column single-pass number is 13–15x, larger than the synthetic 7.2x,
because Jayway re-parses the full ~1.4KB DOM once per column (jaywayMultiColumn ≈ single-field ÷
12) — exactly the per-row-per-column re-parse this change removes. This is the case for wiring the
single-pass extractor into jsonExtractScalar (follow-up).

(The production-log harness reads local NDJSON files and so is not committed; it is reproducible
against any NDJSON via -Dbench.dir. The committed BenchmarkJsonPathExtraction covers the
synthetic payload above.)

Testing

pinot-common: 575 tests green (incl. the differential + fuzz). pinot-core: 218 green
(JsonExtractScalarTransformFunctionTest + its fast-path subclass, JsonExtractScalarTest,
JsonExtractIndexTransformFunctionTest). spotless / checkstyle / license clean. Additionally
validated against 100k real production log lines (1.5M extractions) with zero differences from
Jayway, as noted under Performance.

Notes for reviewers

  • earlyExit changes results, so it must be flipped cluster-wide, not mid-rolling-restart — a
    half-restarted cluster would have servers disagree within one scatter-gather. enabled alone is
    result-preserving and rolls normally.
  • The single-pass multi-path extract is included and benchmarked but not yet wired into
    jsonExtractScalar (which would need sibling transform functions to share one pass over the source
    column); that is the follow-up where the 7x lands in ingestion.

@codecov-commenter

codecov-commenter commented Jul 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.21344% with 45 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.24%. Comparing base (9a93618) to head (7756195).

Files with missing lines Patch % Lines
...ot/common/function/StreamingJsonPathExtractor.java 78.68% 16 Missing and 10 partials ⚠️
...g/apache/pinot/common/function/SimpleJsonPath.java 84.70% 10 Missing and 3 partials ⚠️
...m/function/JsonExtractScalarTransformFunction.java 60.00% 4 Missing and 2 partials ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #18972      +/-   ##
============================================
+ Coverage     65.21%   65.24%   +0.02%     
  Complexity     1405     1405              
============================================
  Files          3418     3421       +3     
  Lines        214642   214887     +245     
  Branches      33919    33980      +61     
============================================
+ Hits         139981   140205     +224     
- Misses        63366    63386      +20     
- Partials      11295    11296       +1     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (?)
java-21 65.24% <82.21%> (+0.02%) ⬆️
temurin 65.24% <82.21%> (+0.02%) ⬆️
unittests 65.24% <82.21%> (+0.02%) ⬆️
unittests1 56.91% <81.42%> (+0.06%) ⬆️
unittests2 37.70% <9.09%> (-0.04%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@xiangfu0 xiangfu0 force-pushed the claude/streaming-json-path-extractor-19a1e2 branch from ad8ac93 to a7147f4 Compare July 11, 2026 08:02
@xiangfu0 xiangfu0 added the json Related to JSON column support label Jul 11, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a Jackson streaming-based JsonPath extractor optimized for simple linear paths and wires it into both query-time scalar functions and ingestion-time jsonExtractScalar, behind two default-OFF feature flags to preserve existing Jayway semantics.

Changes:

  • Introduces SimpleJsonPath + StreamingJsonPathExtractor and a global mode switch (JsonPathFastPathMode) for fast-path enablement / early-exit behavior.
  • Wires the fast path into JsonFunctions.jsonPath* and JsonExtractScalarTransformFunction, with startup-time config seeding via ServiceStartableUtils.
  • Adds differential + fuzz testing for parity against the Jayway fallback, plus fast-path re-runs of existing JSON function / transform-function test suites, and a JMH benchmark.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
pinot-spi/src/main/java/org/apache/pinot/spi/utils/CommonConstants.java Adds cluster config keys for fast-path enablement and early-exit.
pinot-common/src/main/java/org/apache/pinot/common/function/JsonPathFastPathMode.java Adds global volatile switches (system-property seeded) for fast-path and early-exit.
pinot-common/src/main/java/org/apache/pinot/common/function/SimpleJsonPath.java Compiles/caches the restricted “simple linear” JsonPath subset for streaming resolution.
pinot-common/src/main/java/org/apache/pinot/common/function/StreamingJsonPathExtractor.java Implements single-/multi-path streaming extraction via Jackson JsonParser.
pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java Routes eligible jsonPath* calls through the streaming extractor when enabled.
pinot-common/src/main/java/org/apache/pinot/common/utils/ServiceStartableUtils.java Seeds fast-path flags from cluster config during service startup.
pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunction.java Applies streaming fast path to ingestion jsonExtractScalar for STRING/BYTES inputs when eligible.
pinot-common/src/test/java/org/apache/pinot/common/function/StreamingJsonPathExtractorTest.java Differential + fuzz parity tests (Jayway oracle), plus explicit early-exit divergence assertions.
pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsFastPathTest.java Re-runs existing JsonFunctionsTest under fast-path enabled.
pinot-core/src/test/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunctionFastPathTest.java Re-runs existing JsonExtractScalarTransformFunctionTest under fast-path enabled.
pinot-common/src/test/java/org/apache/pinot/common/function/JsonPathFastPathModeTest.java Tests startup wiring/config assignment for the two feature flags.
pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonPathExtraction.java Adds JMH benchmarks comparing Jayway vs streaming extraction (single + multi-path).

@xiangfu0 xiangfu0 force-pushed the claude/streaming-json-path-extractor-19a1e2 branch 2 times, most recently from 95eca38 to 0a618e0 Compare July 14, 2026 04:51
xiangfu0 added 2 commits July 14, 2026 01:03
Ingestion and query-time JsonPath extraction goes through Jayway
(parse(json).read(path)), which builds a full Jackson DOM of the document
and only then walks to the field. This happens once per row per derived
column and is a major ingestion CPU cost.

This adds a streaming Jackson-based extractor for simple linear paths
($ followed only by .key, ['key'], [int]), falling back to Jayway for
anything else (wildcards, deep scan, filters, unions, slices, negative
indices, non-String/byte[] input, and any document whose first
non-whitespace char is not { or [).

Feature-flagged, both flags default OFF:
  - pinot.jsonpath.fastpath.enabled          - gate the fast path
  - pinot.jsonpath.fastpath.earlyExit.enabled - stop at the leaf

The default (full-scan) path is byte-for-byte identical to Jayway,
including duplicate-key last-wins and raising on a document malformed
inside its root value. Early exit trades those two behaviors (first
duplicate wins; a malformed tail after the leaf is not seen) for a large
speedup when the field appears early; both inputs are RFC-8259-undefined
or corrupt.

Server-local compute only: no wire format, segment format, or config
schema change, so mixed-version safe with no migration.

Correctness gate: StreamingJsonPathExtractorTest is a differential test
with the production Jayway fallback as oracle - the same jsonPath*
entry points run flag-off vs flag-on and must agree, over a hand-picked
matrix plus 60k fuzz iterations, the byte[] overloads, and the
BigDecimal context. JsonFunctionsFastPathTest and
JsonExtractScalarTransformFunctionFastPathTest re-run the existing
corpora through the fast path.

JMH (BenchmarkJsonPathExtraction, Apple M, 688B nested payload):
jsonPathString 458k -> 836k ops/s (1.8x, the Jackson lex floor) at exact
parity; 5.09M (11x) with early exit when the field is early. Four
columns from one document via the single-pass multi-path extractor:
106k -> 763k ops/s (7.2x) at exact parity.
SimpleJsonPath.compile returns null for non-linear paths, so the field is
null whenever the extraction falls back to Jayway. Mark it @nullable and
reword the Javadoc to state the null case, per review feedback.
@xiangfu0 xiangfu0 force-pushed the claude/streaming-json-path-extractor-19a1e2 branch from 0a618e0 to 10c200e Compare July 14, 2026 08:03
@xiangfu0 xiangfu0 requested a review from Copilot July 14, 2026 08:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 6 comments.

Review found three real issues:

1. Enabling the fast path REGRESSED non-simple paths. JsonFunctions.jsonPath
   compiles a SimpleJsonPath per row, and the Guava maximumSize cache takes a
   segment lock on every read to maintain its LRU recency queue. That is a
   second contended cache read per row on top of Jayway's own, measured at
   ~+11% on a rejected path at 8 threads (invisible single-threaded). Replaced
   with a ConcurrentHashMap: lock-free reads, growth bounded by refusing new
   entries when full (re-parsing a path is a few dozen char comparisons, so the
   miss is cheaper than maintaining an eviction policy).

2. The "byte-for-byte identical to Jayway" claim was too strong. Jayway builds a
   DOM of the whole document, so it materializes and validates values the path
   never addresses; this extractor skips those subtrees. A value Jackson can lex
   but not materialize therefore raises in Jayway and not here - an over-long
   string (> maxStringLength), and, under useBigDecimal, a float whose exponent
   overflows an int. Both only differ when the value sits OUTSIDE the addressed
   path; when it IS the addressed value, both raise. This is inherent to the
   optimization (matching it would mean materializing every string in every
   document) and the extractor never returns a different value for the addressed
   path - it only declines to fail on values the caller did not ask about.
   Documented on the class and pinned with a test so it cannot widen unnoticed.

3. The useBigDecimal context - which backs jsonExtractScalar STRING/BIG_DECIMAL,
   the most common result types - was never fuzzed, because the existing fuzzers
   all route through JsonFunctions, which hard-codes it off. That gap is what hid
   (2). Added a dedicated 20k-iteration BigDecimal fuzz against its own oracle.

Also addressed review comments: guard a null jsonPath so it falls back to Jayway
rather than raising a different exception, validate the multi-path out[] length,
correct the startup warning that claimed to ignore a flag it actually applies,
and reset both flags (not just one) in the fast-path test classes, which mutate
global state shared across the fork.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

json Related to JSON column support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants