This is an automated email from the ASF dual-hosted git repository.

xiangfu0 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pinot.git


The following commit(s) were added to refs/heads/master by this push:
     new dce035f9ce7 Add jsonPath*Fast / jsonPath*FirstMatch extractor 
functions (#18972)
dce035f9ce7 is described below

commit dce035f9ce7358fbb3e8c3a45b15e3ea19b5f05e
Author: Xiang Fu <[email protected]>
AuthorDate: Sat Jul 18 18:11:08 2026 -0700

    Add jsonPath*Fast / jsonPath*FirstMatch extractor functions (#18972)
    
    Ingestion 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]), exposed as opt-in scalar functions 
so
    each derived column chooses independently, in two clearly named families:
    
      jsonPathStringFast/jsonPathLongFast/jsonPathDoubleFast(object, path, 
default)
        - streaming, full scan; identical result to the existing Jayway 
jsonPath*
          functions, just faster.
      jsonPathStringFirstMatch/...FirstMatch(object, path, default)
        - streaming, stops at the addressed field; faster still when the field 
is
          early, at the cost of two documented behavior changes on 
undefined/corrupt
          input (duplicate keys resolve to the first occurrence; a document 
malformed
          strictly after the field is not rejected).
    
    Both resolve a simple linear path in a single streaming pass and fall back 
to
    Jayway for complex paths (wildcards, deep scan, filters, unions, slices,
    negative indices) and non-JSON input, and also fall back to Jayway on any
    streaming exception, so an unforeseen streaming bug can only cost a second 
parse
    on a row, never a wrong result. The existing jsonPath* functions and the
    jsonExtractScalar transform are unchanged. Server-local compute only - no 
wire,
    segment, or config-schema change.
    
    Components:
      - SimpleJsonPath: compiles/caches the "simple linear chain" JsonPath 
subset,
        returning null (-> Jayway) for anything else. Plain ConcurrentHashMap 
cache
        (lock-free reads) rather than a size-bounded Guava cache that locks per 
read.
      - StreamingJsonPathExtractor: single forward pass with skipChildren() on
        non-addressed subtrees; single- and multi-path overloads (the multi-path
        single-pass form and byte[] overloads are engine support for 
follow-ups).
      - JsonFunctions: the six opt-in scalar functions above.
    
    Testing:
      - StreamingJsonPathExtractorTest: differential test against the exact 
Jayway
        config it replaces (value level and through the Fast functions), a hand-
        picked matrix (missing paths, invalid JSON, null/empty/whitespace/BOM, 
type
        mismatches, nested arrays, unicode, duplicate keys, big/precise numbers,
        dotted keys), randomized fuzz incl. a dedicated BigDecimal-context 
fuzz, the
        one documented full-scan divergence pinned, the FirstMatch duplicate-key
        divergence pinned, and a FunctionRegistry resolution check.
      - JsonPathTest: derives columns via the new functions through ingestion
        transform configs and asserts they equal the Jayway-derived column on 
both
        query engines.
      - BenchmarkJsonPathExtraction (pinot-perf): Jayway vs streaming, single- 
and
        multi-column.
    
    Note on one full-scan divergence: Jayway materializes the whole document, 
so it
    rejects a value it can lex but not materialize (a string over 
maxStringLength, or
    a float with an int-overflow exponent under BigDecimal) even outside the
    addressed path; the streaming extractor skips such subtrees and returns the
    requested value. It never returns a different value for the addressed path.
---
 .../common/function/FastJsonPathExtractor.java     | 326 +++++++++
 .../pinot/common/function/SimpleJsonPath.java      | 217 ++++++
 .../common/function/scalar/JsonFunctions.java      | 128 ++++
 .../common/function/FastJsonPathExtractorTest.java | 734 +++++++++++++++++++++
 .../integration/tests/custom/JsonPathTest.java     |  29 +
 .../pinot/perf/BenchmarkJsonPathExtraction.java    | 172 +++++
 6 files changed, 1606 insertions(+)

diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
new file mode 100644
index 00000000000..2979d8b4bcc
--- /dev/null
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/FastJsonPathExtractor.java
@@ -0,0 +1,326 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.common.function;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.google.common.base.Preconditions;
+import com.jayway.jsonpath.InvalidJsonException;
+import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import javax.annotation.Nullable;
+
+
+/// Resolves one or more [SimpleJsonPath]s against a JSON document in a single 
forward pass of a Jackson
+/// [JsonParser], materializing only the addressed values. Subtrees that no 
path descends into are consumed
+/// with `skipChildren()`, so no intermediate `Map` / `List` is ever built for 
them. This replaces
+/// `JsonPath.parse(json).read(path)`, which builds a full Jackson DOM of the 
document and only then walks to
+/// the field.
+///
+/// **Semantics.** The results are byte-for-byte identical to Jayway 
configured as Pinot configures it
+/// (`JacksonJsonProvider` + `JacksonMappingProvider` + 
`Option.SUPPRESS_EXCEPTIONS`), including:
+/// - a missing key, an out-of-range index, indexing a non-array, descending 
into a scalar, and an explicit
+///   JSON `null` all resolve to `null`;
+/// - duplicate object keys resolve to the **last** occurrence, and a later 
occurrence discards whatever an
+///   earlier one resolved to, at every depth;
+/// - a container leaf is returned as a `LinkedHashMap` / `ArrayList` with the 
same element types;
+/// - numbers become `Integer` / `Long` / `BigInteger` by magnitude, and 
`Double` (or `BigDecimal` when
+///   `useBigDecimal` is set) for floating-point literals;
+/// - a document that is malformed anywhere inside its root value raises 
[InvalidJsonException], even when the
+///   addressed field was already seen; content *after* the root value is 
ignored, exactly as
+///   `ObjectMapper.readValue` ignores it (`FAIL_ON_TRAILING_TOKENS` is off by 
default).
+///
+/// **The one place it deliberately differs.** Jayway builds a DOM of the 
*whole* document, so it materializes
+/// and validates every value in it - including fields the path never 
addresses. This extractor skips those
+/// subtrees, so a value that Jackson can lex but cannot *materialize* raises 
in Jayway and not here. Two such
+/// values exist, and both only matter when they sit **outside** the addressed 
path:
+/// 1. a string longer than `StreamReadConstraints` `maxStringLength` 
(20,000,000 chars);
+/// 2. when `useBigDecimal` is set, a float literal whose exponent overflows 
an `int` (e.g. `1e999999999999`),
+///    which `BigDecimal` cannot represent.
+/// Jayway rejects such a document outright; this extractor returns the 
requested value. When the offending
+/// value *is* the addressed one, both raise, so the results still agree.
+///
+/// This is inherent to the optimization - not materializing what was not 
asked for is precisely what makes it
+/// fast, and matching Jayway here would mean materializing every string in 
every document. 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`).
+///
+/// **Early exit.** When the caller passes `earlyExit`, the pass stops as soon 
as every requested path has
+/// resolved to a non-null value. That is much faster when the target fields 
appear early in the document, at
+/// the cost of two behavior changes, since the tail is never read:
+/// 1. duplicate keys resolve to the **first** occurrence rather than the last;
+/// 2. a document malformed strictly after the last addressed field yields the 
extracted value instead of
+///    raising [InvalidJsonException].
+/// Both inputs are undefined (RFC 8259) or corrupt, and the switch is off by 
default.
+///
+/// **Applicability.** Callers must first check [#canExtract]: the document's 
first non-whitespace character
+/// has to be `{` or `[`. Bare scalar documents, `null`, empty / 
whitespace-only input, a leading byte-order
+/// mark and plain text all take the Jayway path, which reproduces its exact 
exceptions for them.
+///
+/// Stateless and thread-safe; the shared [JsonFactory] and [ObjectMapper]s 
are safe for concurrent use.
+public final class FastJsonPathExtractor {
+  private FastJsonPathExtractor() {
+  }
+
+  /// The live-path set is tracked as a bitmask, so a single call cannot 
resolve more paths than a `long` has
+  /// bits. Callers with more paths must batch them.
+  public static final int MAX_PATHS = Long.SIZE;
+
+  /// Deliberately the plain, unconfigured factory and mappers that Jayway's 
`JacksonJsonProvider` builds, so
+  /// that stream-read constraints (nesting depth, string length) and number 
handling match exactly.
+  private static final JsonFactory FACTORY = new JsonFactory();
+  private static final ObjectMapper MAPPER = new ObjectMapper();
+  private static final ObjectMapper MAPPER_WITH_BIG_DECIMAL =
+      new 
ObjectMapper().configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, 
true);
+
+  /// Returns `true` when `json`'s first non-whitespace character starts a 
JSON object or array, which is the
+  /// only shape this extractor handles. Everything else must go through 
Jayway.
+  public static boolean canExtract(@Nullable String json) {
+    if (json == null) {
+      return false;
+    }
+    for (int i = 0, n = json.length(); i < n; i++) {
+      char c = json.charAt(i);
+      if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
+        continue;
+      }
+      return c == '{' || c == '[';
+    }
+    return false;
+  }
+
+  /// UTF-8 overload of [#canExtract(String)]. A byte-order mark is rejected, 
matching the `String` overload.
+  public static boolean canExtract(@Nullable byte[] json) {
+    if (json == null) {
+      return false;
+    }
+    for (byte b : json) {
+      if (b == ' ' || b == '\t' || b == '\n' || b == '\r') {
+        continue;
+      }
+      return b == '{' || b == '[';
+    }
+    return false;
+  }
+
+  /// Resolves a single `path` against `json`, returning the addressed value 
or `null` when it resolves to a
+  /// missing key, an out-of-range index, a type mismatch, or an explicit JSON 
`null` (matching Jayway under
+  /// `SUPPRESS_EXCEPTIONS`). Raises [InvalidJsonException] if the document is 
malformed inside its root value.
+  /// The two short-lived arrays cost a few ns against a ~900ns parse, so the 
single-path case reuses the
+  /// multi-path walk rather than duplicating it; split out a dedicated 
single-path walk only if a profile says so.
+  @Nullable
+  public static Object extract(String json, SimpleJsonPath path, boolean 
useBigDecimal, boolean earlyExit) {
+    Object[] result = new Object[1];
+    try (JsonParser parser = FACTORY.createParser(json)) {
+      extract(parser, new SimpleJsonPath[]{path}, result, useBigDecimal, 
earlyExit);
+    } catch (IOException e) {
+      throw new InvalidJsonException(e);
+    }
+    return result[0];
+  }
+
+  /// UTF-8 overload of [#extract(String, SimpleJsonPath, boolean, boolean)] 
with the same return and exception
+  /// contract.
+  @Nullable
+  public static Object extract(byte[] json, SimpleJsonPath path, boolean 
useBigDecimal, boolean earlyExit) {
+    Object[] result = new Object[1];
+    try (JsonParser parser = FACTORY.createParser(json)) {
+      extract(parser, new SimpleJsonPath[]{path}, result, useBigDecimal, 
earlyExit);
+    } catch (IOException e) {
+      throw new InvalidJsonException(e);
+    }
+    return result[0];
+  }
+
+  /// Resolves every path in `paths` in one pass, writing path `i`'s value 
into `out[i]`. `out` must be at
+  /// least as long as `paths` and is fully overwritten for the first 
`paths.length` entries.
+  public static void extract(String json, SimpleJsonPath[] paths, Object[] 
out, boolean useBigDecimal,
+      boolean earlyExit) {
+    try (JsonParser parser = FACTORY.createParser(json)) {
+      extract(parser, paths, out, useBigDecimal, earlyExit);
+    } catch (IOException e) {
+      throw new InvalidJsonException(e);
+    }
+  }
+
+  private static void extract(JsonParser parser, SimpleJsonPath[] paths, 
Object[] out, boolean useBigDecimal,
+      boolean earlyExit)
+      throws IOException {
+    int numPaths = paths.length;
+    Preconditions.checkArgument(numPaths > 0 && numPaths <= MAX_PATHS, 
"Expected 1 to %s paths, got: %s", MAX_PATHS,
+        numPaths);
+    Preconditions.checkArgument(out.length >= numPaths, "Expected an output 
array of at least %s entries, got: %s",
+        numPaths, out.length);
+    for (int i = 0; i < numPaths; i++) {
+      out[i] = null;
+    }
+    JsonToken token = parser.nextToken();
+    if (token != JsonToken.START_OBJECT && token != JsonToken.START_ARRAY) {
+      /// Guarded by canExtract(); a bare scalar addresses nothing under a 
non-empty linear path.
+      return;
+    }
+    long live = numPaths == MAX_PATHS ? -1L : (1L << numPaths) - 1;
+    walk(parser, paths, live, 0, out, useBigDecimal, earlyExit);
+  }
+
+  /// Walks the container the parser is currently positioned on, resolving the 
paths in `live` whose segment at
+  /// `depth` matches. Consumes the container's closing token, unless it 
returns `true`, which means every path
+  /// has resolved and the caller should stop reading immediately (early-exit 
mode only).
+  private static boolean walk(JsonParser parser, SimpleJsonPath[] paths, long 
live, int depth, Object[] out,
+      boolean useBigDecimal, boolean earlyExit)
+      throws IOException {
+    boolean isObject = parser.currentToken() == JsonToken.START_OBJECT;
+    int index = 0;
+    while (true) {
+      JsonToken token = parser.nextToken();
+      if (token == JsonToken.END_OBJECT || token == JsonToken.END_ARRAY) {
+        return false;
+      }
+      long leaves = 0;
+      long children = 0;
+      if (isObject) {
+        String name = parser.currentName();
+        parser.nextToken();
+        for (long mask = live; mask != 0; mask &= mask - 1) {
+          int i = Long.numberOfTrailingZeros(mask);
+          SimpleJsonPath path = paths[i];
+          if (!path.matchesKey(depth, name)) {
+            continue;
+          }
+          /// Jackson keeps the last of a set of duplicate keys, so a later 
occurrence must discard whatever an
+          /// earlier one resolved to - including values resolved deeper down 
inside it.
+          out[i] = null;
+          if (depth == path.length() - 1) {
+            leaves |= 1L << i;
+          } else {
+            children |= 1L << i;
+          }
+        }
+      } else {
+        int elementIndex = index++;
+        for (long mask = live; mask != 0; mask &= mask - 1) {
+          int i = Long.numberOfTrailingZeros(mask);
+          SimpleJsonPath path = paths[i];
+          if (!path.matchesIndex(depth, elementIndex)) {
+            continue;
+          }
+          if (depth == path.length() - 1) {
+            leaves |= 1L << i;
+          } else {
+            children |= 1L << i;
+          }
+        }
+      }
+
+      JsonToken value = parser.currentToken();
+      boolean container = value == JsonToken.START_OBJECT || value == 
JsonToken.START_ARRAY;
+      if (leaves != 0) {
+        /// At least one path ends here. Materialize the value once; any path 
that wants to descend further
+        /// (e.g. both `$.a` and `$.a.b` were requested) navigates the 
materialized value instead of the stream.
+        Object materialized = readValue(parser, value, useBigDecimal);
+        for (long mask = leaves; mask != 0; mask &= mask - 1) {
+          out[Long.numberOfTrailingZeros(mask)] = materialized;
+        }
+        for (long mask = children; mask != 0; mask &= mask - 1) {
+          int i = Long.numberOfTrailingZeros(mask);
+          out[i] = navigate(materialized, paths[i], depth + 1);
+        }
+        if (earlyExit && allResolved(out, paths.length)) {
+          return true;
+        }
+      } else if (children != 0 && container) {
+        if (walk(parser, paths, children, depth + 1, out, useBigDecimal, 
earlyExit)) {
+          return true;
+        }
+      } else {
+        /// Either nothing matches here, or a path wants to descend into a 
scalar - which resolves to null.
+        parser.skipChildren();
+      }
+    }
+  }
+
+  /// Resolves the remaining segments of `path`, from `depth` onwards, against 
an already-materialized value.
+  /// Mirrors Jayway's behavior on the same DOM: any type mismatch, missing 
key or out-of-range index is `null`.
+  @Nullable
+  private static Object navigate(@Nullable Object node, SimpleJsonPath path, 
int depth) {
+    for (int d = depth, n = path.length(); d < n; d++) {
+      String key = path.getKey(d);
+      if (key != null) {
+        if (!(node instanceof Map)) {
+          return null;
+        }
+        node = ((Map<?, ?>) node).get(key);
+      } else {
+        if (!(node instanceof List)) {
+          return null;
+        }
+        List<?> list = (List<?>) node;
+        int index = path.getIndex(d);
+        if (index >= list.size()) {
+          return null;
+        }
+        node = list.get(index);
+      }
+    }
+    return node;
+  }
+
+  /// Reads the value the parser is positioned on, leaving the parser on that 
value's last token. Containers are
+  /// materialized through the same `ObjectMapper` configuration Jayway uses, 
so their contents match exactly.
+  @Nullable
+  private static Object readValue(JsonParser parser, JsonToken token, boolean 
useBigDecimal)
+      throws IOException {
+    switch (token) {
+      case VALUE_STRING:
+        return parser.getText();
+      case VALUE_NUMBER_INT:
+        return parser.getNumberValue();
+      case VALUE_NUMBER_FLOAT:
+        return useBigDecimal ? parser.getDecimalValue() : 
parser.getNumberValue();
+      case VALUE_TRUE:
+        return Boolean.TRUE;
+      case VALUE_FALSE:
+        return Boolean.FALSE;
+      case VALUE_NULL:
+        return null;
+      case START_OBJECT:
+      case START_ARRAY:
+        return (useBigDecimal ? MAPPER_WITH_BIG_DECIMAL : 
MAPPER).readValue(parser, Object.class);
+      default:
+        throw new IllegalStateException("Unexpected JSON token: " + token);
+    }
+  }
+
+  /// A path whose value is an explicit JSON `null` never counts as resolved, 
so early exit degrades to a full
+  /// pass for those documents. That is the conservative direction: the extra 
scan can only add exceptions and
+  /// later duplicates that a full pass would have found anyway.
+  private static boolean allResolved(Object[] out, int numPaths) {
+    for (int i = 0; i < numPaths; i++) {
+      if (out[i] == null) {
+        return false;
+      }
+    }
+    return true;
+  }
+}
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/SimpleJsonPath.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/SimpleJsonPath.java
new file mode 100644
index 00000000000..d97c47d8930
--- /dev/null
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/SimpleJsonPath.java
@@ -0,0 +1,217 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.common.function;
+
+import com.google.common.annotations.VisibleForTesting;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.ConcurrentHashMap;
+import javax.annotation.Nullable;
+
+
+/// A JsonPath expression restricted to a *simple linear chain*: a `$` root 
followed only by object-key and
+/// array-index steps. Such a path addresses at most one value and can be 
resolved by a single forward pass
+/// over the JSON document, which is what [FastJsonPathExtractor] does.
+///
+/// Accepted syntax:
+/// - `.name` where `name` is made of letters, digits, `_` or `-`
+/// - `['name']` or `["name"]` — the quoted key is taken literally (a `.` 
inside it does **not** nest)
+/// - `[123]` — a non-negative array index
+///
+/// Everything else compiles to `null` and must fall back to Jayway: wildcards 
(`*`), deep scan (`..`),
+/// filters (`[?(...)]`), unions (`['a','b']`), slices (`[1:2]`), functions, 
negative indices (`[-1]`, which
+/// Jayway resolves from the end and therefore needs the array length), 
bracket keys containing a backslash
+/// (Jayway's bracket-escape handling is not reproducible here), a bare `$`, 
and any path not starting
+/// with `$`.
+///
+/// Compilation results are cached per literal path string; in practice a path 
is a query or transform-config
+/// literal, so the cache is warm after the first row.
+///
+/// The cache is a plain [ConcurrentHashMap] rather than a Guava 
`CacheBuilder.maximumSize` cache on purpose:
+/// this is read on the per-row path, and Guava's size-bounded cache takes a 
segment lock on every *read* to
+/// maintain its LRU recency queue, which measurably contends across query 
threads. Growth is bounded instead
+/// by simply not admitting new entries past [#CACHE_MAXIMUM_SIZE] - a path 
that misses a full cache is
+/// re-parsed, which is a few dozen character comparisons, so the fallback is 
cheap and there is no eviction
+/// policy to maintain.
+///
+/// Immutable and thread-safe.
+public final class SimpleJsonPath {
+  private static final int CACHE_MAXIMUM_SIZE = 10_000;
+
+  /// Sentinel for "not a simple linear path". Distinguishable from any real 
instance because a real one always
+  /// has at least one segment (a bare `$` is deliberately rejected).
+  private static final SimpleJsonPath NOT_SIMPLE = new SimpleJsonPath(new 
String[0], new int[0]);
+
+  private static final ConcurrentHashMap<String, SimpleJsonPath> CACHE = new 
ConcurrentHashMap<>();
+
+  /// Segment `i` is an object key when `_keys[i] != null`, and an array index 
(`_indices[i]`) otherwise.
+  private final String[] _keys;
+  private final int[] _indices;
+
+  private SimpleJsonPath(String[] keys, int[] indices) {
+    _keys = keys;
+    _indices = indices;
+  }
+
+  /// Returns the compiled path, or `null` when `jsonPath` is `null` or is not 
a simple linear chain, in which
+  /// case the caller must fall back to Jayway. A `null` path is deliberately 
reported the same way rather than
+  /// raising, so that the fast path never changes which exception a caller 
sees - Jayway raises its own.
+  @Nullable
+  public static SimpleJsonPath compile(@Nullable String jsonPath) {
+    if (jsonPath == null) {
+      return null;
+    }
+    SimpleJsonPath compiled = CACHE.get(jsonPath);
+    if (compiled == null) {
+      compiled = parse(jsonPath);
+      /// Bound the cache by refusing new entries when full: query paths are 
attacker-controllable, and re-parsing
+      /// is cheap enough that an eviction policy would cost more than the 
miss it saves.
+      if (CACHE.size() < CACHE_MAXIMUM_SIZE) {
+        CACHE.putIfAbsent(jsonPath, compiled);
+      }
+    }
+    return compiled == NOT_SIMPLE ? null : compiled;
+  }
+
+  int length() {
+    return _keys.length;
+  }
+
+  /// Returns the object key at `depth`, or `null` when that segment is an 
array index.
+  @Nullable
+  String getKey(int depth) {
+    return _keys[depth];
+  }
+
+  /// Returns the array index at `depth`. Only meaningful when [#getKey] 
returns `null` for the same depth.
+  int getIndex(int depth) {
+    return _indices[depth];
+  }
+
+  boolean matchesKey(int depth, String key) {
+    return key.equals(_keys[depth]);
+  }
+
+  boolean matchesIndex(int depth, int index) {
+    return _keys[depth] == null && _indices[depth] == index;
+  }
+
+  @VisibleForTesting
+  static SimpleJsonPath parse(String jsonPath) {
+    int length = jsonPath.length();
+    if (length < 2 || jsonPath.charAt(0) != '$') {
+      return NOT_SIMPLE;
+    }
+    List<String> keys = new ArrayList<>(4);
+    List<Integer> indices = new ArrayList<>(4);
+    int pos = 1;
+    while (pos < length) {
+      char c = jsonPath.charAt(pos);
+      if (c == '.') {
+        int start = ++pos;
+        while (pos < length && isNameChar(jsonPath.charAt(pos))) {
+          pos++;
+        }
+        if (pos == start) {
+          /// "..", ".*", a trailing "." or a name starting with an 
unsupported character.
+          return NOT_SIMPLE;
+        }
+        keys.add(jsonPath.substring(start, pos));
+        indices.add(-1);
+      } else if (c == '[') {
+        pos++;
+        if (pos == length) {
+          return NOT_SIMPLE;
+        }
+        char quote = jsonPath.charAt(pos);
+        if (quote == '\'' || quote == '"') {
+          int start = ++pos;
+          while (pos < length && jsonPath.charAt(pos) != quote) {
+            if (jsonPath.charAt(pos) == '\\') {
+              return NOT_SIMPLE;
+            }
+            pos++;
+          }
+          if (pos == length) {
+            return NOT_SIMPLE;
+          }
+          String key = jsonPath.substring(start, pos);
+          /// Skip the closing quote; anything other than ']' next means a 
union such as ['a','b'].
+          if (++pos == length || jsonPath.charAt(pos++) != ']') {
+            return NOT_SIMPLE;
+          }
+          keys.add(key);
+          indices.add(-1);
+        } else {
+          int start = pos;
+          while (pos < length && isDigit(jsonPath.charAt(pos))) {
+            pos++;
+          }
+          /// Rejects [*], [?(...)], [-1], [1,2] and [1:2] because none of 
them close right after the digits.
+          if (pos == start || pos == length || jsonPath.charAt(pos++) != ']') {
+            return NOT_SIMPLE;
+          }
+          long index;
+          try {
+            index = Long.parseLong(jsonPath, start, pos - 1, 10);
+          } catch (NumberFormatException e) {
+            return NOT_SIMPLE;
+          }
+          if (index > Integer.MAX_VALUE) {
+            return NOT_SIMPLE;
+          }
+          keys.add(null);
+          indices.add((int) index);
+        }
+      } else {
+        return NOT_SIMPLE;
+      }
+    }
+    if (keys.isEmpty()) {
+      return NOT_SIMPLE;
+    }
+    int size = keys.size();
+    int[] indexArray = new int[size];
+    for (int i = 0; i < size; i++) {
+      indexArray[i] = indices.get(i);
+    }
+    return new SimpleJsonPath(keys.toArray(new String[0]), indexArray);
+  }
+
+  private static boolean isNameChar(char c) {
+    return Character.isLetterOrDigit(c) || c == '_' || c == '-';
+  }
+
+  private static boolean isDigit(char c) {
+    return c >= '0' && c <= '9';
+  }
+
+  @Override
+  public String toString() {
+    StringBuilder sb = new StringBuilder("$");
+    for (int i = 0; i < _keys.length; i++) {
+      if (_keys[i] != null) {
+        sb.append("['").append(_keys[i]).append("']");
+      } else {
+        sb.append('[').append(_indices[i]).append(']');
+      }
+    }
+    return sb.toString();
+  }
+}
diff --git 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java
 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java
index eac8e4e4af4..d21129dd68a 100644
--- 
a/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java
+++ 
b/pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java
@@ -37,7 +37,9 @@ import java.util.List;
 import java.util.Map;
 import javax.annotation.Nullable;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.pinot.common.function.FastJsonPathExtractor;
 import org.apache.pinot.common.function.JsonPathCache;
+import org.apache.pinot.common.function.SimpleJsonPath;
 import org.apache.pinot.spi.annotations.ScalarFunction;
 import org.apache.pinot.spi.utils.JsonUtils;
 
@@ -105,6 +107,31 @@ public class JsonFunctions {
     return PARSE_CONTEXT.parse(object).read(jsonPath, NO_PREDICATES);
   }
 
+  /// Resolves `jsonPath` against `object` using the fast extractor 
([FastJsonPathExtractor]) when
+  /// `object` is a JSON `String` and the path is a simple linear chain, and 
falls back to the Jayway
+  /// [#jsonPath] implementation otherwise, so a caller sees the same value 
and the same exceptions either way.
+  /// Backs the opt-in fast scalar functions below. See 
[FastJsonPathExtractor] for the one edge case
+  /// where the fast and Jayway results differ.
+  @Nullable
+  private static Object fastJsonPath(Object object, String jsonPath, boolean 
useBigDecimal, boolean earlyExit) {
+    if (object instanceof String) {
+      String json = (String) object;
+      SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(jsonPath);
+      if (simpleJsonPath != null && FastJsonPathExtractor.canExtract(json)) {
+        try {
+          return FastJsonPathExtractor.extract(json, simpleJsonPath, 
useBigDecimal, earlyExit);
+        } catch (Exception e) {
+          /// Any failure in the fast path degrades to the reference Jayway 
implementation, so an unforeseen
+          /// fast bug can never change a caller's result - at worst it costs 
a second parse on that row.
+          /// Malformed JSON also lands here: Jayway then raises the same 
exception the fast path would have.
+          return jsonPath(json, jsonPath);
+        }
+      }
+    }
+    /// Complex path, non-JSON input, or an already-parsed container: the 
existing Jayway implementation.
+    return jsonPath(object, jsonPath);
+  }
+
   /**
    * Returns {@code false} when the input is known to be non-extractable by a 
json path without invoking the JSON
    * parser: a {@code null} value, or a string whose first non-whitespace 
character cannot begin a JSON value. Used
@@ -221,6 +248,41 @@ public class JsonFunctions {
     }
   }
 
+  /// Opt-in fast variant of [#jsonPathString(Object, String, String)] with 
identical results: a simple
+  /// linear path over a JSON string is resolved in a single forward pass 
instead of building a full Jayway DOM
+  /// (see [FastJsonPathExtractor]), and complex paths / non-JSON input fall 
back to Jayway. Faster; choose
+  /// this when the value matters and the result must match `jsonPathString`.
+  @ScalarFunction(nullableParameters = true)
+  public static String jsonPathStringFast(@Nullable Object object, String 
jsonPath, String defaultValue) {
+    return fastJsonPathString(object, jsonPath, defaultValue, false);
+  }
+
+  /// Like [#jsonPathStringFast] but stops the pass at the addressed field. 
Faster still when the field appears
+  /// early, at the cost of two behavior changes on undefined/corrupt input: 
duplicate object keys resolve to the
+  /// **first** occurrence (Jayway and `jsonPathString` take the last), and a 
document that is malformed strictly
+  /// **after** the addressed field is not rejected. Choose this only when the 
source JSON is known to be
+  /// well-formed and free of duplicate keys.
+  @ScalarFunction(nullableParameters = true)
+  public static String jsonPathStringFirstMatch(@Nullable Object object, 
String jsonPath, String defaultValue) {
+    return fastJsonPathString(object, jsonPath, defaultValue, true);
+  }
+
+  private static String fastJsonPathString(@Nullable Object object, String 
jsonPath, String defaultValue,
+      boolean earlyExit) {
+    if (!canExtractJsonPath(object)) {
+      return defaultValue;
+    }
+    try {
+      Object jsonValue = fastJsonPath(object, jsonPath, false, earlyExit);
+      if (jsonValue instanceof String) {
+        return (String) jsonValue;
+      }
+      return jsonValue == null ? defaultValue : 
JsonUtils.objectToString(jsonValue);
+    } catch (Exception ignore) {
+      return defaultValue;
+    }
+  }
+
   /**
    * Extract from Json with path to Long
    */
@@ -251,6 +313,39 @@ public class JsonFunctions {
     }
   }
 
+  /// Opt-in fast variant of [#jsonPathLong(Object, String, long)] with 
identical results; see
+  /// [#jsonPathStringFast] for the fast/Jayway-fallback contract.
+  @ScalarFunction(nullableParameters = true)
+  public static long jsonPathLongFast(@Nullable Object object, String 
jsonPath, long defaultValue) {
+    return fastJsonPathLong(object, jsonPath, defaultValue, false);
+  }
+
+  /// Early-exit fast variant of [#jsonPathLong(Object, String, long)]; see 
[#jsonPathStringFirstMatch] for
+  /// the two behavior changes and when to use it.
+  @ScalarFunction(nullableParameters = true)
+  public static long jsonPathLongFirstMatch(@Nullable Object object, String 
jsonPath, long defaultValue) {
+    return fastJsonPathLong(object, jsonPath, defaultValue, true);
+  }
+
+  private static long fastJsonPathLong(@Nullable Object object, String 
jsonPath, long defaultValue,
+      boolean earlyExit) {
+    if (!canExtractJsonPath(object)) {
+      return defaultValue;
+    }
+    try {
+      Object jsonValue = fastJsonPath(object, jsonPath, false, earlyExit);
+      if (jsonValue == null) {
+        return defaultValue;
+      }
+      if (jsonValue instanceof Number) {
+        return ((Number) jsonValue).longValue();
+      }
+      return Long.parseLong(jsonValue.toString());
+    } catch (Exception ignore) {
+      return defaultValue;
+    }
+  }
+
   /**
    * Extract from Json with path to Double
    */
@@ -281,6 +376,39 @@ public class JsonFunctions {
     }
   }
 
+  /// Opt-in fast variant of [#jsonPathDouble(Object, String, double)] with 
identical results; see
+  /// [#jsonPathStringFast] for the fast/Jayway-fallback contract.
+  @ScalarFunction(nullableParameters = true)
+  public static double jsonPathDoubleFast(@Nullable Object object, String 
jsonPath, double defaultValue) {
+    return fastJsonPathDouble(object, jsonPath, defaultValue, false);
+  }
+
+  /// Early-exit fast variant of [#jsonPathDouble(Object, String, double)]; 
see [#jsonPathStringFirstMatch]
+  /// for the two behavior changes and when to use it.
+  @ScalarFunction(nullableParameters = true)
+  public static double jsonPathDoubleFirstMatch(@Nullable Object object, 
String jsonPath, double defaultValue) {
+    return fastJsonPathDouble(object, jsonPath, defaultValue, true);
+  }
+
+  private static double fastJsonPathDouble(@Nullable Object object, String 
jsonPath, double defaultValue,
+      boolean earlyExit) {
+    if (!canExtractJsonPath(object)) {
+      return defaultValue;
+    }
+    try {
+      Object jsonValue = fastJsonPath(object, jsonPath, false, earlyExit);
+      if (jsonValue == null) {
+        return defaultValue;
+      }
+      if (jsonValue instanceof Number) {
+        return ((Number) jsonValue).doubleValue();
+      }
+      return Double.parseDouble(jsonValue.toString());
+    } catch (Exception ignore) {
+      return defaultValue;
+    }
+  }
+
   /**
    * Extract an array of key-value maps to a map.
    * E.g. input: [{"key": "k1", "value": "v1"}, {"key": "k2", "value": "v2"}, 
{"key": "k3", "value": "v3"}]
diff --git 
a/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
 
b/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
new file mode 100644
index 00000000000..a29ac274f84
--- /dev/null
+++ 
b/pinot-common/src/test/java/org/apache/pinot/common/function/FastJsonPathExtractorTest.java
@@ -0,0 +1,734 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.common.function;
+
+import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.StreamReadFeature;
+import com.fasterxml.jackson.databind.DeserializationFeature;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.jayway.jsonpath.Configuration;
+import com.jayway.jsonpath.JsonPath;
+import com.jayway.jsonpath.Option;
+import com.jayway.jsonpath.ParseContext;
+import com.jayway.jsonpath.Predicate;
+import com.jayway.jsonpath.spi.json.JacksonJsonProvider;
+import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Objects;
+import java.util.Random;
+import java.util.StringJoiner;
+import org.apache.pinot.common.function.scalar.JsonFunctions;
+import org.testng.annotations.DataProvider;
+import org.testng.annotations.Test;
+
+import static org.testng.Assert.assertEquals;
+import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertTrue;
+import static org.testng.Assert.fail;
+
+
+/// Differential test for {@link FastJsonPathExtractor} and the opt-in fast 
scalar overloads, with
+/// Jayway as the oracle.
+/// <p>
+/// Every case runs the same {json, path} pair through the fast extractor and 
through the exact Jayway
+/// configuration it replaces, and asserts the two produce the same value - or 
the same exception type. The
+/// oracle is the real production Jayway config ({@link #PLAIN_CONTEXT} / 
{@link #BIG_DECIMAL_CONTEXT}) and the
+/// existing Jayway scalar overloads, so it cannot drift away from what Pinot 
actually does today.
+public class FastJsonPathExtractorTest {
+  private static final Predicate[] NO_PREDICATES = new Predicate[0];
+
+  /// Rejects the two inputs early exit is allowed to disagree on: duplicate 
keys and a malformed / trailing tail.
+  private static final JsonFactory STRICT_FACTORY =
+      
JsonFactory.builder().enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build();
+
+  /// Mirrors {@code JsonExtractScalarTransformFunction.JSON_PARSER_CONTEXT}.
+  private static final ParseContext PLAIN_CONTEXT = JsonPath.using(
+      new Configuration.ConfigurationBuilder().jsonProvider(new 
JacksonJsonProvider())
+          .mappingProvider(new 
JacksonMappingProvider()).options(Option.SUPPRESS_EXCEPTIONS).build());
+
+  /// Mirrors {@code 
JsonExtractScalarTransformFunction.JSON_PARSER_CONTEXT_WITH_BIG_DECIMAL}, the 
one production
+  /// context that is not reachable through {@link JsonFunctions}.
+  private static final ParseContext BIG_DECIMAL_CONTEXT = JsonPath.using(
+      new Configuration.ConfigurationBuilder().jsonProvider(new 
JacksonJsonProvider(
+              new 
ObjectMapper().configure(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS, 
true)))
+          .mappingProvider(new 
JacksonMappingProvider()).options(Option.SUPPRESS_EXCEPTIONS).build());
+
+  static {
+    /// JsonFunctions registers Pinot's JsonPath cache in its static 
initializer, and Jayway rejects that
+    /// registration once its own cache has been read. This test reads through 
Jayway directly, so load
+    /// JsonFunctions first or its class initialization fails.
+    JsonFunctions.jsonPathExists("{}", "$.x");
+  }
+
+  /// The value a call produced, or the type of the exception it threw.
+  private static final class Outcome {
+    final Object _value;
+    final Class<?> _thrown;
+
+    private Outcome(Object value, Class<?> thrown) {
+      _value = value;
+      _thrown = thrown;
+    }
+
+    static Outcome of(ThrowingSupplier supplier) {
+      try {
+        return new Outcome(supplier.get(), null);
+      } catch (Exception e) {
+        return new Outcome(null, e.getClass());
+      }
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      if (!(o instanceof Outcome)) {
+        return false;
+      }
+      Outcome other = (Outcome) o;
+      return _thrown == other._thrown && Objects.deepEquals(_value, 
other._value);
+    }
+
+    @Override
+    public int hashCode() {
+      return Objects.hash(_thrown, _value);
+    }
+
+    @Override
+    public String toString() {
+      if (_thrown != null) {
+        return "threw " + _thrown.getSimpleName();
+      }
+      return _value == null ? "null" : _value.getClass().getSimpleName() + "(" 
+ _value + ")";
+    }
+  }
+
+  @FunctionalInterface
+  private interface ThrowingSupplier {
+    Object get()
+        throws Exception;
+  }
+
+  /// Each typed extractor that has an opt-in fast overload, paired with its 
existing Jayway overload. The
+  /// two must return the same thing for every input; that is what makes the 
fast overload a safe drop-in.
+  private enum Fn {
+    STRING((o, p) -> JsonFunctions.jsonPathString(o, p, "DEFAULT"),
+        (o, p) -> JsonFunctions.jsonPathStringFast(o, p, "DEFAULT")),
+    LONG((o, p) -> JsonFunctions.jsonPathLong(o, p, -7L),
+        (o, p) -> JsonFunctions.jsonPathLongFast(o, p, -7L)),
+    DOUBLE((o, p) -> JsonFunctions.jsonPathDouble(o, p, -7.5d),
+        (o, p) -> JsonFunctions.jsonPathDoubleFast(o, p, -7.5d));
+
+    private final Invocation _jayway;
+    private final Invocation _fast;
+
+    Fn(Invocation jayway, Invocation fast) {
+      _jayway = jayway;
+      _fast = fast;
+    }
+
+    Outcome jayway(String json, String path) {
+      return Outcome.of(() -> _jayway.invoke(json, path));
+    }
+
+    Outcome fast(String json, String path) {
+      return Outcome.of(() -> _fast.invoke(json, path));
+    }
+  }
+
+  @FunctionalInterface
+  private interface Invocation {
+    Object invoke(Object json, String path)
+        throws Exception;
+  }
+
+  /// Asserts full parity with Jayway at two levels: the extractor itself 
against the exact Jayway config it
+  /// replaces (value level, covering raw values and containers), and every 
opt-in fast scalar overload
+  /// against its existing Jayway overload (function level, covering the 
coercion and fallback wiring).
+  private static void assertParity(String json, String path) {
+    SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(path);
+    if (simpleJsonPath != null && FastJsonPathExtractor.canExtract(json)) {
+      Outcome jayway = Outcome.of(() -> PLAIN_CONTEXT.parse(json).read(path, 
NO_PREDICATES));
+      Outcome fast = Outcome.of(() -> FastJsonPathExtractor.extract(json, 
simpleJsonPath, false, false));
+      assertEquals(fast, jayway,
+          String.format("extractor mismatch for json=<%s> path=<%s>: fast=%s 
jayway=%s", json, path, fast,
+              jayway));
+    }
+    for (Fn fn : Fn.values()) {
+      Outcome jayway = fn.jayway(json, path);
+      Outcome fast = fn.fast(json, path);
+      assertEquals(fast, jayway,
+          String.format("%s mismatch for json=<%s> path=<%s>: fast=%s 
jayway=%s", fn, json, path, fast,
+              jayway));
+    }
+  }
+
+  @DataProvider(name = "parityCases")
+  public Object[][] parityCases() {
+    return new Object[][]{
+        /// Scalar leaves, all JSON types.
+        {"{\"a\":\"s\"}", "$.a"},
+        {"{\"a\":1}", "$.a"},
+        {"{\"a\":true}", "$.a"},
+        {"{\"a\":false}", "$.a"},
+        {"{\"a\":null}", "$.a"},
+
+        /// Missing paths, at every depth and shape.
+        {"{\"a\":1}", "$.b"},
+        {"{\"a\":1}", "$.b.c"},
+        {"{\"a\":1}", "$.a.b.c.d"},
+        {"{\"a\":{\"b\":{}}}", "$.a.b.c"},
+        {"{\" a\":1}", "$.a"},
+
+        /// Type mismatches: descend into a scalar / array, index a non-array, 
out of range.
+        {"{\"a\":5}", "$.a.b"},
+        {"{\"a\":\"xy\"}", "$.a[0]"},
+        {"{\"a\":[1,2]}", "$.a.b"},
+        {"{\"a\":{\"x\":1}}", "$.a[0]"},
+        {"{\"a\":[10,20]}", "$.a[5]"},
+        {"{\"a\":null}", "$.a.b"},
+        {"{\"a\":null}", "$.a[0]"},
+        {"[1,2,3]", "$.a"},
+        {"{\"a\":1}", "$[0]"},
+
+        /// Roots.
+        {"[10,20,30]", "$[0]"},
+        {"[10,20,30]", "$[5]"},
+        {"{\"a\":1}", "$['a']"},
+        {"{\"a\":1}", "$[\"a\"]"},
+
+        /// Nested arrays and arrays of objects.
+        {"{\"a\":[{\"b\":7}]}", "$.a[0].b"},
+        {"{\"a\":[{\"b\":7}]}", "$.a[1].b"},
+        {"{\"a\":[[1,2]]}", "$.a[0][1]"},
+        {"{\"a\":[[1,2]]}", "$.a[0][9]"},
+        {"{\"a\":[10,20]}", "$.a[01]"},
+        {"{\"a\":[10,20]}", "$.a[ 1 ]"},
+        {"[[{\"x\":[5]}]]", "$[0][0].x[0]"},
+
+        /// Container leaves.
+        {"{\"a\":{\"x\":1,\"y\":[1,2]}}", "$.a"},
+        {"{\"a\":[1,\"two\",null,{\"b\":3}]}", "$.a"},
+        {"{\"a\":[]}", "$.a"},
+        {"{\"a\":{}}", "$.a"},
+        {"{\"a\":{\"b\":{\"c\":{\"d\":[1,{\"e\":2}]}}}}", "$.a.b.c"},
+
+        /// Deep nesting.
+        {"{\"a\":{\"b\":{\"c\":{\"d\":{\"e\":{\"f\":42}}}}}}", 
"$.a.b.c.d.e.f"},
+
+        /// Dotted / quoted / empty keys.
+        {"{\"a.b\":1}", "$['a.b']"},
+        {"{\"a\":{\"b\":1}}", "$['a.b']"},
+        {"{\"a\":{\"b\":1}}", "$.a['b']"},
+        {"{\"a\":{\"b\":1}}", "$['a'].b"},
+        {"{\"\":1}", "$['']"},
+        {"{\"a b\":1}", "$['a b']"},
+        {"{\"a\\\"b\":1}", "$['a\"b']"},
+        {"{\"a'b\":1}", "$[\"a'b\"]"},
+        {"{\"a-b\":1}", "$.a-b"},
+        {"{\"a_b\":1}", "$.a_b"},
+        {"{\"0\":1}", "$['0']"},
+
+        /// Duplicate keys: last occurrence wins, at every depth, and a later 
one discards the earlier subtree.
+        {"{\"a\":1,\"a\":2}", "$.a"},
+        {"{\"a\":{\"x\":1},\"a\":{\"x\":2}}", "$.a.x"},
+        {"{\"a\":{\"x\":1},\"a\":{\"y\":2}}", "$.a.x"},
+        {"{\"a\":{\"x\":1},\"a\":5}", "$.a.x"},
+        {"{\"a\":5,\"a\":{\"x\":1}}", "$.a.x"},
+        {"{\"a\":{\"x\":1,\"x\":2}}", "$.a.x"},
+        {"{\"a\":[1],\"a\":[2]}", "$.a[0]"},
+        {"{\"a\":1,\"a\":null}", "$.a"},
+        {"{\"a\":null,\"a\":1}", "$.a"},
+
+        /// Numbers: every boundary the untyped Jackson deserializer switches 
on.
+        {"{\"a\":2147483647}", "$.a"},
+        {"{\"a\":2147483648}", "$.a"},
+        {"{\"a\":-2147483648}", "$.a"},
+        {"{\"a\":-2147483649}", "$.a"},
+        {"{\"a\":9223372036854775807}", "$.a"},
+        {"{\"a\":9223372036854775808}", "$.a"},
+        {"{\"a\":-9223372036854775809}", "$.a"},
+        {"{\"a\":1.5}", "$.a"},
+        {"{\"a\":1.0}", "$.a"},
+        {"{\"a\":-0.0}", "$.a"},
+        {"{\"a\":0.1}", "$.a"},
+        {"{\"a\":1E2}", "$.a"},
+        {"{\"a\":1e400}", "$.a"},
+        {"{\"a\":-1e400}", "$.a"},
+        {"{\"a\":1e-400}", "$.a"},
+        {"{\"a\":123456789012345678901234567890.123}", "$.a"},
+        {"{\"a\":[1,2147483648,1.5]}", "$.a"},
+
+        /// Unicode escapes, surrogate pairs, control characters, escaped keys.
+        {"{\"a\":\"caf\\u00e9\"}", "$.a"},
+        {"{\"a\":\"\\ud83d\\ude00\"}", "$.a"},
+        {"{\"a\":\"\\u0000\"}", "$.a"},
+        {"{\"a\":\"line\\nbreak\\ttab\\\\slash\\\"quote\"}", "$.a"},
+        {"{\"\\u0061\":1}", "$.a"},
+        {"{\"a\":\"\\/\"}", "$.a"},
+
+        /// Invalid JSON: before, at and after the addressed field; and 
structurally broken.
+        {"{\"a\":1,\"b\":}", "$.a"},
+        {"{\"b\":,\"a\":1}", "$.a"},
+        {"{\"a\":1,\"b\":[1,", "$.a"},
+        {"{\"a\":1,", "$.a"},
+        {"{\"a\":1,}", "$.a"},
+        {"{\"a\":01}", "$.a"},
+        {"{\"a\":NaN}", "$.a"},
+        {"{'a':1}", "$.a"},
+        {"{a:1}", "$.a"},
+        {"{\"a\":1,\"b\":\"\\uZZZZ\"}", "$.a"},
+        {"[1,2", "$[0]"},
+        {"{", "$.a"},
+
+        /// Content after the root value is ignored, exactly as 
ObjectMapper.readValue ignores it.
+        {"{\"a\":1} xyz", "$.a"},
+        {"{\"a\":1}{\"a\":2}", "$.a"},
+        {"[1,2] xyz", "$[0]"},
+
+        /// Non-JSON, empty, whitespace, BOM, bare scalars: all must take the 
Jayway path.
+        {"hello world", "$.a"},
+        {"", "$.a"},
+        {"   ", "$.a"},
+        {"{\"a\":1}", "$.a"},
+        {"5", "$.a"},
+        {"\"str\"", "$.a"},
+        {"true", "$.a"},
+        {"null", "$.a"},
+        {"nullx", "$.a"},
+        {"  {\"a\":1}  ", "$.a"},
+
+        /// Paths the fast path must decline, so these assert the fallback is 
wired correctly.
+        {"{\"a\":{\"b\":1}}", "$..b"},
+        {"{\"a\":{\"b\":1}}", "$.*"},
+        {"{\"a\":[1,2,3]}", "$.a[*]"},
+        {"{\"a\":[1,2,3]}", "$.a[-1]"},
+        {"{\"a\":[1,2,3]}", "$.a[0:2]"},
+        {"{\"a\":[1,2,3]}", "$.a.length()"},
+        {"{\"a\":[{\"b\":1},{\"b\":2}]}", "$.a[?(@.b==2)]"},
+        {"{\"a\":1,\"b\":2}", "$['a','b']"},
+        {"{\"a\":1}", "$"},
+        {"{\"a\":1}", "a"},
+
+        /// String coercions that only bite the typed wrappers.
+        {"{\"a\":\"123\"}", "$.a"},
+        {"{\"a\":\"12.5\"}", "$.a"},
+        {"{\"a\":\"not a number\"}", "$.a"},
+        {"{\"a\":{\"b\":1}}", "$.a"},
+    };
+  }
+
+  @Test(dataProvider = "parityCases")
+  public void testParityWithJayway(String json, String path) {
+    assertParity(json, path);
+  }
+
+  /// The {@code USE_BIG_DECIMAL_FOR_FLOATS} context used by {@code 
jsonExtractScalar} for STRING and BIG_DECIMAL
+  /// results is not reachable through {@link JsonFunctions}, so it gets its 
own oracle.
+  @Test(dataProvider = "parityCases")
+  public void testBigDecimalParityWithJayway(String json, String path) {
+    SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(path);
+    if (simpleJsonPath == null || !FastJsonPathExtractor.canExtract(json)) {
+      return;
+    }
+    Outcome jayway = Outcome.of(() -> 
BIG_DECIMAL_CONTEXT.parse(json).read(path, NO_PREDICATES));
+    Outcome fast = Outcome.of(() -> FastJsonPathExtractor.extract(json, 
simpleJsonPath, true, false));
+    assertEquals(fast, jayway,
+        String.format("big-decimal mismatch for json=<%s> path=<%s>: fast=%s 
jayway=%s", json, path, fast,
+            jayway));
+  }
+
+  /// The {@code byte[]} overloads back {@code jsonExtractScalar} over a BYTES 
column, which reaches
+  /// {@code parseContext.parseUtf8} rather than {@code parse}. They get their 
own oracle.
+  @Test(dataProvider = "parityCases")
+  public void testBytesParityWithJayway(String json, String path) {
+    SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(path);
+    byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8);
+    assertEquals(FastJsonPathExtractor.canExtract(jsonBytes), 
FastJsonPathExtractor.canExtract(json),
+        "canExtract disagrees between String and byte[] for json=<" + json + 
">");
+    if (simpleJsonPath == null || 
!FastJsonPathExtractor.canExtract(jsonBytes)) {
+      return;
+    }
+    Outcome jayway = Outcome.of(() -> 
PLAIN_CONTEXT.parseUtf8(jsonBytes).read(path, NO_PREDICATES));
+    Outcome fast = Outcome.of(() -> FastJsonPathExtractor.extract(jsonBytes, 
simpleJsonPath, false, false));
+    assertEquals(fast, jayway,
+        String.format("bytes mismatch for json=<%s> path=<%s>: fast=%s 
jayway=%s", json, path, fast, jayway));
+  }
+
+  /// Multibyte UTF-8 in keys and values, which only the {@code byte[]} path 
can get wrong.
+  @Test
+  public void testBytesParityWithMultibyteContent() {
+    for (String[] jsonAndPath : new String[][]{
+        {"{\"a\":\"café\"}", "$.a"}, {"{\"café\":1}", "$.café"}, 
{"{\"café\":1}", "$['café']"},
+        {"{\"a\":\"日本語\"}", "$.a"}, {"{\"日本\":{\"語\":7}}", "$.日本.語"}, 
{"{\"a\":\"😀\"}", "$.a"},
+        {"{\"a\":\"😀\",\"b\":2}", "$.b"}
+    }) {
+      testBytesParityWithJayway(jsonAndPath[0], jsonAndPath[1]);
+      assertParity(jsonAndPath[0], jsonAndPath[1]);
+    }
+  }
+
+  /// The opt-in fast overloads are new SQL-visible signatures, so resolve and 
invoke them through the
+  /// {@link FunctionRegistry} the query/ingestion path uses. This is what a 
Java-level call cannot prove: that
+  /// the four-argument overloads register under their own arity and that a 
{@code boolean} literal converts.
+  @Test
+  public void testFastFunctionsResolveThroughFunctionRegistry()
+      throws Exception {
+    String json = "{\"user\":{\"country\":\"US\",\"age\":41,\"score\":9.5}}";
+    assertEquals(invoke("jsonPathStringFast", json, "$.user.country", 
"DEFAULT"), "US");
+    assertEquals(invoke("jsonPathStringFirstMatch", json, "$.user.country", 
"DEFAULT"), "US");
+    assertEquals(invoke("jsonPathStringFast", json, "$.missing", "DEFAULT"), 
"DEFAULT");
+    assertEquals(invoke("jsonPathLongFirstMatch", json, "$.user.age", -7L), 
41L);
+    assertEquals(invoke("jsonPathDoubleFast", json, "$.user.score", -7.5d), 
9.5d);
+    /// A complex path must still resolve through the function by falling back 
to Jayway, i.e. produce exactly
+    /// what the existing Jayway function produces (here the deep-scan list, 
stringified).
+    assertEquals(invoke("jsonPathStringFast", json, "$..country", "DEFAULT"),
+        JsonFunctions.jsonPathString(json, "$..country", "DEFAULT"));
+  }
+
+  /// Function-level coverage of the {@code FirstMatch} (early-exit) 
functions: they equal {@code Fast}/Jayway on
+  /// clean data, and diverge to the first occurrence on the documented 
duplicate-key case.
+  @Test
+  public void testFirstMatchFunctions() {
+    String clean = "{\"a\":{\"b\":\"x\"},\"n\":{\"m\":7}}";
+    assertEquals(JsonFunctions.jsonPathStringFirstMatch(clean, "$.a.b", "d"),
+        JsonFunctions.jsonPathStringFast(clean, "$.a.b", "d"));
+    assertEquals(JsonFunctions.jsonPathLongFirstMatch(clean, "$.n.m", -1L),
+        JsonFunctions.jsonPathLongFast(clean, "$.n.m", -1L));
+
+    String duplicateKeys = "{\"a\":1,\"a\":2}";
+    assertEquals(JsonFunctions.jsonPathLong(duplicateKeys, "$.a", -1L), 2L, 
"Jayway takes the last occurrence");
+    assertEquals(JsonFunctions.jsonPathLongFast(duplicateKeys, "$.a", -1L), 
2L, "Fast matches Jayway");
+    assertEquals(JsonFunctions.jsonPathLongFirstMatch(duplicateKeys, "$.a", 
-1L), 1L, "FirstMatch takes the first");
+  }
+
+  private static Object invoke(String name, Object... arguments)
+      throws Exception {
+    FunctionInfo functionInfo = FunctionRegistry.getFunctionInfo(name, 
arguments.length);
+    assertNotNull(functionInfo, name + "/" + arguments.length + " is not 
registered");
+    FunctionInvoker invoker = new FunctionInvoker(functionInfo);
+    Object[] copy = arguments.clone();
+    invoker.convertTypes(copy);
+    return invoker.invoke(copy);
+  }
+
+  /// Invalid UTF-8 must raise the same exception type Jayway raises.
+  @Test
+  public void testBytesParityWithInvalidUtf8() {
+    byte[] invalid = {'{', '"', 'a', '"', ':', '"', (byte) 0xC3, '"', '}'};
+    SimpleJsonPath path = SimpleJsonPath.compile("$.a");
+    assertNotNull(path);
+    assertTrue(FastJsonPathExtractor.canExtract(invalid));
+    Outcome jayway = Outcome.of(() -> 
PLAIN_CONTEXT.parseUtf8(invalid).read("$.a", NO_PREDICATES));
+    Outcome fast = Outcome.of(() -> FastJsonPathExtractor.extract(invalid, 
path, false, false));
+    assertEquals(fast, jayway);
+  }
+
+  /// The {@code long} live-path bitmask special-cases a full 64-path batch; 
anything more must be rejected.
+  @Test
+  public void testMaxPathsBoundary() {
+    String json = "{\"k0\":0,\"k63\":63,\"k64\":64}";
+    SimpleJsonPath[] paths = new 
SimpleJsonPath[FastJsonPathExtractor.MAX_PATHS];
+    for (int i = 0; i < paths.length; i++) {
+      paths[i] = SimpleJsonPath.compile("$.k" + i);
+    }
+    Object[] out = new Object[paths.length];
+    FastJsonPathExtractor.extract(json, paths, out, false, false);
+    assertEquals(out[0], 0);
+    assertEquals(out[63], 63);
+    assertNull(out[1]);
+
+    SimpleJsonPath[] tooMany = new 
SimpleJsonPath[FastJsonPathExtractor.MAX_PATHS + 1];
+    Arrays.fill(tooMany, paths[0]);
+    try {
+      FastJsonPathExtractor.extract(json, tooMany, new Object[tooMany.length], 
false, false);
+      fail("must reject more paths than the bitmask can hold");
+    } catch (IllegalArgumentException e) {
+      /// Expected.
+    }
+  }
+
+  /// Pins the one documented divergence from Jayway in full-scan mode. Jayway 
materializes the whole document, so
+  /// a value it can lex but not materialize makes it reject the document even 
when the path never addresses that
+  /// value; the fast extractor skips such subtrees and returns the requested 
value. Matching Jayway here
+  /// would mean materializing every string in every document, which is 
exactly the cost this class exists to
+  /// avoid. Asserted explicitly so it can never widen unnoticed.
+  @Test
+  public void testUnmaterializableValueOutsideThePathDivergesFromJayway() {
+    SimpleJsonPath path = SimpleJsonPath.compile("$.a");
+    assertNotNull(path);
+
+    /// (1) float literal whose exponent overflows an int, only 
unrepresentable under useBigDecimal.
+    String hostileFloat = "{\"other\":1e999999999999,\"a\":1}";
+    Outcome jaywayBigDecimal = Outcome.of(() -> 
BIG_DECIMAL_CONTEXT.parse(hostileFloat).read("$.a", NO_PREDICATES));
+    assertNotNull(jaywayBigDecimal._thrown, "Jayway must still reject the 
hostile float under useBigDecimal");
+    assertEquals(FastJsonPathExtractor.extract(hostileFloat, path, true, 
false), 1,
+        "fast skips the unaddressed subtree and returns the addressed value");
+    /// The plain (double) context represents it as Infinity, so there is no 
divergence there.
+    assertParity(hostileFloat, "$.a");
+    /// When the hostile literal IS the addressed value, both raise - parity 
holds.
+    String addressedHostileFloat = "{\"a\":1e999999999999}";
+    try {
+      FastJsonPathExtractor.extract(addressedHostileFloat, path, true, false);
+      fail("addressing the hostile float must still raise under 
useBigDecimal");
+    } catch (Exception e) {
+      /// Expected, same as Jayway.
+    }
+
+    /// (2) string longer than StreamReadConstraints.maxStringLength 
(20,000,000), outside the path.
+    String hostileString = "{\"a\":\"ERROR\",\"other\":\"" + 
"x".repeat(20_000_001) + "\"}";
+    Outcome jaywayLongString = Outcome.of(() -> 
PLAIN_CONTEXT.parse(hostileString).read("$.a", NO_PREDICATES));
+    assertNotNull(jaywayLongString._thrown, "Jayway must still reject the 
over-long unaddressed string");
+    assertEquals(FastJsonPathExtractor.extract(hostileString, path, false, 
false), "ERROR",
+        "fast never materializes the unaddressed string, so it does not hit 
the limit");
+  }
+
+  /// The {@code useBigDecimal} context backs {@code jsonExtractScalar(..., 
'STRING')} and {@code 'BIG_DECIMAL'} -
+  /// the most common result types - but is unreachable through {@link 
JsonFunctions}, so the other fuzzers never
+  /// touch it. Fuzz it directly against its own Jayway oracle.
+  @Test
+  public void testBigDecimalFuzzAgainstJayway() {
+    Random random = new Random(4242L);
+    for (int i = 0; i < 20_000; i++) {
+      String json = randomJson(random, 0);
+      String path = randomPath(random);
+      SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(path);
+      if (simpleJsonPath == null || !FastJsonPathExtractor.canExtract(json)) {
+        continue;
+      }
+      Outcome jayway = Outcome.of(() -> 
BIG_DECIMAL_CONTEXT.parse(json).read(path, NO_PREDICATES));
+      Outcome fast = Outcome.of(() -> FastJsonPathExtractor.extract(json, 
simpleJsonPath, true, false));
+      assertEquals(fast, jayway,
+          String.format("big-decimal fuzz mismatch for json=<%s> path=<%s>", 
json, path));
+    }
+  }
+
+  @Test
+  public void testCompileAcceptsSimpleLinearPaths() {
+    for (String path : new String[]{
+        "$.a", "$.a.b", "$['a']", "$[\"a\"]", "$[0]", "$.a[0]", "$.a[0].b", 
"$['a.b']", "$.a['b'][2]", "$['']",
+        "$.a-b", "$.a_b", "$.abc123", "$[2147483647]",
+        /// Jayway reads a leading zero as a plain decimal index, so this is 
index 1, not a rejected literal.
+        "$[01]"
+    }) {
+      assertNotNull(SimpleJsonPath.compile(path), path);
+    }
+  }
+
+  @Test
+  public void testCompileRejectsEverythingElse() {
+    for (String path : new String[]{
+        "$", "$..a", "$.*", "$..*", "$.a[*]", "$.a[-1]", "$.a[0:2]", 
"$.a[:2]", "$.a[1,2]", "$['a','b']",
+        "$.a[?(@.b==2)]", "$.a.length()", "$.a.", "$..", "a", "a.b", "", "$.", 
"$[", "$[']", "$['a", "$['a'",
+        "$['a\\'b']", "$['a']extra", "$.a b", "$[2147483648]", "$[ 1 ]", 
"$.@", "$.a$b"
+    }) {
+      assertNull(SimpleJsonPath.compile(path), path);
+    }
+  }
+
+  /// The two behavior changes early exit buys, asserted explicitly so they 
can never regress silently.
+  @Test
+  public void testEarlyExitDivergences() {
+    String duplicateKeys = "{\"a\":1,\"a\":2}";
+    String malformedTail = "{\"a\":1,\"b\":}";
+    SimpleJsonPath path = SimpleJsonPath.compile("$.a");
+    assertNotNull(path);
+
+    assertEquals(FastJsonPathExtractor.extract(duplicateKeys, path, false, 
false), 2,
+        "full pass takes the last key");
+    try {
+      FastJsonPathExtractor.extract(malformedTail, path, false, false);
+      fail("full pass must reject a document malformed inside its root value");
+    } catch (Exception e) {
+      /// Expected: same as Jayway.
+    }
+
+    assertEquals(FastJsonPathExtractor.extract(duplicateKeys, path, false, 
true), 1,
+        "early exit takes the first key");
+    assertEquals(FastJsonPathExtractor.extract(malformedTail, path, false, 
true), 1,
+        "early exit never reads the malformed tail");
+  }
+
+  /// A JSON document that parses cleanly with duplicate-key detection on, and 
has nothing after its root value, is
+  /// exactly a document for which early exit provably cannot change the 
answer. Those must match a full pass.
+  private static boolean isStrictAndSingleRooted(String json) {
+    try (JsonParser parser = STRICT_FACTORY.createParser(json)) {
+      while (parser.nextToken() != null) {
+        /// Reading every token also rejects trailing content, which a full 
pass would ignore but never see.
+      }
+      return true;
+    } catch (Exception e) {
+      return false;
+    }
+  }
+
+  private static void assertEarlyExitParity(String json, String path) {
+    if (!isStrictAndSingleRooted(json)) {
+      return;
+    }
+    SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(path);
+    if (simpleJsonPath == null || !FastJsonPathExtractor.canExtract(json)) {
+      return;
+    }
+    Outcome fullPass = Outcome.of(() -> FastJsonPathExtractor.extract(json, 
simpleJsonPath, false, false));
+    Outcome earlyExit = Outcome.of(() -> FastJsonPathExtractor.extract(json, 
simpleJsonPath, false, true));
+    assertEquals(earlyExit, fullPass, String.format("early-exit mismatch for 
json=<%s> path=<%s>", json, path));
+  }
+
+  /// Everything but a duplicate key or a malformed tail must be identical 
whether or not early exit is on.
+  @Test(dataProvider = "parityCases")
+  public void testEarlyExitParityExceptKnownDivergences(String json, String 
path) {
+    assertEarlyExitParity(json, path);
+  }
+
+  @Test
+  public void testEarlyExitFuzzParityExceptKnownDivergences() {
+    Random random = new Random(556677L);
+    for (int i = 0; i < 20_000; i++) {
+      assertEarlyExitParity(randomJson(random, 0), randomPath(random));
+    }
+  }
+
+  @Test
+  public void testMultiPathMatchesSinglePath() {
+    String json = 
"{\"user\":{\"id\":7,\"country\":\"US\"},\"tags\":[\"a\",\"b\"],\"total\":1.5,\"nil\":null}";
+    String[] paths = {"$.user.country", "$.user.id", "$.tags[1]", "$.total", 
"$.nil", "$.missing", "$.user",
+        "$.user.missing"};
+    SimpleJsonPath[] compiled = new SimpleJsonPath[paths.length];
+    for (int i = 0; i < paths.length; i++) {
+      compiled[i] = SimpleJsonPath.compile(paths[i]);
+      assertNotNull(compiled[i], paths[i]);
+    }
+    Object[] batch = new Object[paths.length];
+    FastJsonPathExtractor.extract(json, compiled, batch, false, false);
+    for (int i = 0; i < paths.length; i++) {
+      assertEquals(batch[i], FastJsonPathExtractor.extract(json, compiled[i], 
false, false), paths[i]);
+      assertEquals(batch[i], PLAIN_CONTEXT.parse(json).read(paths[i], 
NO_PREDICATES), paths[i]);
+    }
+  }
+
+  /// A path that is a strict prefix of another must not consume the value the 
longer path needs.
+  @Test
+  public void testMultiPathWithOverlappingPrefixes() {
+    String json = "{\"a\":{\"b\":{\"c\":1}}}";
+    SimpleJsonPath[] compiled = {
+        SimpleJsonPath.compile("$.a"), SimpleJsonPath.compile("$.a.b"), 
SimpleJsonPath.compile("$.a.b.c"),
+        SimpleJsonPath.compile("$.a.b.missing")
+    };
+    Object[] batch = new Object[compiled.length];
+    FastJsonPathExtractor.extract(json, compiled, batch, false, false);
+    assertEquals(batch[0].toString(), "{b={c=1}}");
+    assertEquals(batch[1].toString(), "{c=1}");
+    assertEquals(batch[2], 1);
+    assertNull(batch[3]);
+  }
+
+  @Test
+  public void testFuzzAgainstJayway() {
+    Random random = new Random(20260709L);
+    for (int i = 0; i < 20_000; i++) {
+      String json = randomJson(random, 0);
+      String path = randomPath(random);
+      assertParity(json, path);
+    }
+  }
+
+  /// Same corpus, but the paths are generated to actually hit something more 
often than not.
+  @Test
+  public void testFuzzWithDuplicateKeysAndDeepNesting() {
+    Random random = new Random(981723L);
+    for (int i = 0; i < 20_000; i++) {
+      StringJoiner object = new StringJoiner(",", "{", "}");
+      int numFields = 1 + random.nextInt(4);
+      for (int f = 0; f < numFields; f++) {
+        /// A small key alphabet makes duplicate keys frequent, which is the 
case early exit gets wrong.
+        object.add("\"" + KEYS[random.nextInt(3)] + "\":" + randomJson(random, 
2));
+      }
+      assertParity(object.toString(), randomPath(random));
+    }
+  }
+
+  private static final String[] KEYS = {"a", "b", "c", "a-b", "a.b", ""};
+  private static final String[] SCALARS = {
+      "1", "0", "-1", "2147483648", "9223372036854775808", "1.5", "-0.0", 
"1e400", "1E2",
+      "123456789012345678901234567890.123", "true", "false", "null", "\"\"", 
"\"s\"", "\"caf\\u00e9\"",
+      "\"\\ud83d\\ude00\"", "\"123\""
+  };
+
+  private static String randomJson(Random random, int depth) {
+    int choice = depth >= 3 ? 2 : random.nextInt(5);
+    if (choice == 0) {
+      StringJoiner object = new StringJoiner(",", "{", "}");
+      int numFields = random.nextInt(4);
+      for (int i = 0; i < numFields; i++) {
+        object.add("\"" + KEYS[random.nextInt(KEYS.length)] + "\":" + 
randomJson(random, depth + 1));
+      }
+      return object.toString();
+    }
+    if (choice == 1) {
+      StringJoiner array = new StringJoiner(",", "[", "]");
+      int numElements = random.nextInt(4);
+      for (int i = 0; i < numElements; i++) {
+        array.add(randomJson(random, depth + 1));
+      }
+      return array.toString();
+    }
+    return SCALARS[random.nextInt(SCALARS.length)];
+  }
+
+  private static String randomPath(Random random) {
+    StringBuilder path = new StringBuilder("$");
+    int numSegments = 1 + random.nextInt(4);
+    for (int i = 0; i < numSegments; i++) {
+      switch (random.nextInt(4)) {
+        case 0:
+          path.append('.').append(KEYS[random.nextInt(3)]);
+          break;
+        case 1:
+          
path.append("['").append(KEYS[random.nextInt(KEYS.length)]).append("']");
+          break;
+        case 2:
+          path.append('[').append(random.nextInt(4)).append(']');
+          break;
+        default:
+          
path.append("[\"").append(KEYS[random.nextInt(KEYS.length)]).append("\"]");
+          break;
+      }
+    }
+    return path.toString();
+  }
+
+  /// The fuzz corpora must actually reach the fast path, otherwise the test 
proves nothing.
+  @Test
+  public void testFuzzCorpusExercisesTheFastPath() {
+    Random random = new Random(20260709L);
+    List<String> handled = new ArrayList<>();
+    for (int i = 0; i < 20_000; i++) {
+      String json = randomJson(random, 0);
+      String path = randomPath(random);
+      if (SimpleJsonPath.compile(path) != null && 
FastJsonPathExtractor.canExtract(json)) {
+        handled.add(path);
+      }
+    }
+    assertTrue(handled.size() > 5_000, "fuzz corpus only reached the fast path 
" + handled.size() + " times");
+  }
+}
diff --git 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
index 94af87e0bb7..95a9cceafae 100644
--- 
a/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
+++ 
b/pinot-integration-tests/src/test/java/org/apache/pinot/integration/tests/custom/JsonPathTest.java
@@ -60,6 +60,9 @@ public class JsonPathTest extends 
CustomDataQueryClusterIntegrationTest {
   private static final String MY_MAP_STR_FIELD_NAME = "myMapStr";
   private static final String MY_MAP_STR_K1_FIELD_NAME = "myMapStr_k1";
   private static final String MY_MAP_STR_K2_FIELD_NAME = "myMapStr_k2";
+  /// Derived columns that exercise the opt-in fast scalar functions through 
the ingestion transform path.
+  private static final String MY_MAP_STR_K1_FAST_FIELD_NAME = 
"myMapStr_k1_fast";
+  private static final String MY_MAP_STR_K1_FIRST_FIELD_NAME = 
"myMapStr_k1_first";
   private static final String COMPLEX_MAP_STR_FIELD_NAME = "complexMapStr";
   private static final String COMPLEX_MAP_STR_K3_FIELD_NAME = 
"complexMapStr_k3";
 
@@ -88,6 +91,8 @@ public class JsonPathTest extends 
CustomDataQueryClusterIntegrationTest {
         .addSingleValueDimension(MY_MAP_STR_FIELD_NAME, DataType.STRING)
         .addSingleValueDimension(MY_MAP_STR_K1_FIELD_NAME, DataType.STRING)
         .addSingleValueDimension(MY_MAP_STR_K2_FIELD_NAME, DataType.STRING)
+        .addSingleValueDimension(MY_MAP_STR_K1_FAST_FIELD_NAME, 
DataType.STRING)
+        .addSingleValueDimension(MY_MAP_STR_K1_FIRST_FIELD_NAME, 
DataType.STRING)
         .addSingleValueDimension(COMPLEX_MAP_STR_FIELD_NAME, DataType.STRING)
         .addMultiValueDimension(COMPLEX_MAP_STR_K3_FIELD_NAME, DataType.STRING)
         .build();
@@ -98,6 +103,10 @@ public class JsonPathTest extends 
CustomDataQueryClusterIntegrationTest {
     List<TransformConfig> transformConfigs = List.of(
         new TransformConfig(MY_MAP_STR_K1_FIELD_NAME, "jsonPathString(" + 
MY_MAP_STR_FIELD_NAME + ", '$.k1')"),
         new TransformConfig(MY_MAP_STR_K2_FIELD_NAME, "jsonPathString(" + 
MY_MAP_STR_FIELD_NAME + ", '$.k2')"),
+        new TransformConfig(MY_MAP_STR_K1_FAST_FIELD_NAME,
+            "jsonPathStringFast(" + MY_MAP_STR_FIELD_NAME + ", '$.k1', 
'DEFAULT')"),
+        new TransformConfig(MY_MAP_STR_K1_FIRST_FIELD_NAME,
+            "jsonPathStringFirstMatch(" + MY_MAP_STR_FIELD_NAME + ", '$.k1', 
'DEFAULT')"),
         new TransformConfig(COMPLEX_MAP_STR_K3_FIELD_NAME, "jsonPathArray(" + 
COMPLEX_MAP_STR_FIELD_NAME + ", '$.k3')")
     );
     IngestionConfig ingestionConfig = new IngestionConfig();
@@ -381,6 +390,26 @@ public class JsonPathTest extends 
CustomDataQueryClusterIntegrationTest {
     assertEquals(pinotResponse.get("totalDocs").asInt(), 0);
   }
 
+  /// End-to-end coverage for the opt-in fast functions. They take an `Object` 
argument, so - like the
+  /// existing `jsonPathString` - they are used through the ingestion 
transform path, not as query-time scalars.
+  /// `myMapStr_k1_fast` / `myMapStr_k1_first` are derived at ingestion via 
`jsonPathStringFast` /
+  /// `jsonPathStringFirstMatch`; this asserts, over real rows on both query 
engines, that they equal the
+  /// Jayway-derived `myMapStr_k1`. The rows have no duplicate keys and no 
malformed content, so `FirstMatch`
+  /// must also agree.
+  @Test(dataProvider = "useBothQueryEngines")
+  void testFastScalarFunctions(boolean useMultiStageQueryEngine)
+      throws Exception {
+    setUseMultiStageQueryEngine(useMultiStageQueryEngine);
+    String query = "SELECT myMapStr_k1, myMapStr_k1_fast, myMapStr_k1_first 
FROM " + getTableName() + " LIMIT 1000";
+    JsonNode rows = postQuery(query).get("resultTable").get("rows");
+    assertTrue(rows.size() > 0, "expected non-empty result set");
+    for (JsonNode row : rows) {
+      String jayway = row.get(0).asText();
+      assertEquals(row.get(1).asText(), jayway, "jsonPathStringFast must equal 
Jayway jsonPathString");
+      assertEquals(row.get(2).asText(), jayway, "jsonPathStringFirstMatch must 
equal Jayway on clean data");
+    }
+  }
+
   @Test
   public void testJsonPathCache() {
     Cache cache = CacheProvider.getCache();
diff --git 
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonPathExtraction.java
 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonPathExtraction.java
new file mode 100644
index 00000000000..04777753f46
--- /dev/null
+++ 
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonPathExtraction.java
@@ -0,0 +1,172 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.pinot.perf;
+
+import com.jayway.jsonpath.Configuration;
+import com.jayway.jsonpath.JsonPath;
+import com.jayway.jsonpath.Option;
+import com.jayway.jsonpath.ParseContext;
+import com.jayway.jsonpath.Predicate;
+import com.jayway.jsonpath.spi.json.JacksonJsonProvider;
+import com.jayway.jsonpath.spi.mapper.JacksonMappingProvider;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.common.function.FastJsonPathExtractor;
+import org.apache.pinot.common.function.SimpleJsonPath;
+import org.apache.pinot.common.function.scalar.JsonFunctions;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Level;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OutputTimeUnit;
+import org.openjdk.jmh.annotations.Param;
+import org.openjdk.jmh.annotations.Scope;
+import org.openjdk.jmh.annotations.Setup;
+import org.openjdk.jmh.annotations.State;
+import org.openjdk.jmh.annotations.Warmup;
+import org.openjdk.jmh.runner.Runner;
+import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/// Compares JsonPath extraction through Jayway (today's implementation, which 
builds a full Jackson DOM of the
+/// document and then walks to the field) against {@link 
FastJsonPathExtractor}.
+/// <p>
+/// The {@code fieldPosition} parameter puts the extracted field either near 
the start or at the very end of a
+/// ~700 byte nested event payload, because that is what decides whether early 
exit can pay off.
+/// <p>
+/// The single-column benchmarks are also the per-row cost of {@code 
jsonExtractScalar}: that transform function
+/// does exactly {@code parseContext.parse(row).read(jsonPath)} per row, so 
measuring the extraction in isolation
+/// measures it without the surrounding {@code ValueBlock} scaffolding.
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 2)
+@Measurement(iterations = 5, time = 3)
+@State(Scope.Benchmark)
+public class BenchmarkJsonPathExtraction {
+  private static final Predicate[] NO_PREDICATES = new Predicate[0];
+
+  /// Exactly the context {@code JsonExtractScalarTransformFunction} and 
{@code JsonFunctions} use.
+  private static final ParseContext PARSE_CONTEXT = JsonPath.using(
+      new Configuration.ConfigurationBuilder().jsonProvider(new 
JacksonJsonProvider())
+          .mappingProvider(new 
JacksonMappingProvider()).options(Option.SUPPRESS_EXCEPTIONS).build());
+
+  private static final String JSON = "{"
+      + "\"ts\":1719878400123,"
+      + 
"\"user\":{\"id\":\"u-19283\",\"country\":\"US\",\"tier\":\"gold\",\"age\":41},"
+      + 
"\"event\":{\"name\":\"checkout\",\"cart\":[{\"sku\":\"A1\",\"qty\":2,\"price\":19.99},"
+      + 
"{\"sku\":\"B7\",\"qty\":1,\"price\":149.5},{\"sku\":\"C3\",\"qty\":5,\"price\":3.25}],"
+      + "\"total\":352.73,\"currency\":\"USD\"},"
+      + 
"\"device\":{\"os\":\"iOS\",\"version\":\"17.4.1\",\"model\":\"iPhone15,3\",\"screen\":{\"w\":1179,"
+      + "\"h\":2556}},"
+      + "\"geo\":{\"lat\":37.7749,\"lon\":-122.4194,\"city\":\"San 
Francisco\",\"region\":\"CA\"},"
+      + 
"\"tags\":[\"mobile\",\"ios\",\"returning\",\"promo-eligible\",\"newsletter\"],"
+      + 
"\"session\":{\"id\":\"s-aaaabbbbccccdddd\",\"start\":1719878300000,\"pages\":14,\"referrer\":"
+      + "\"https://example.com/landing?utm_source=x&utm_medium=y\"},";
+      + "\"trailer\":{\"country\":\"DE\",\"note\":\"last field in the 
document\"}"
+      + "}";
+
+  /// Four derived columns, spread through the document, as an ingestion 
{@code transformConfigs} would pull.
+  private static final String[] FOUR_PATHS = {"$.user.country", 
"$.event.currency", "$.device.os", "$.geo.city"};
+
+  @Param({"early", "late"})
+  private String _fieldPosition;
+
+  private String _path;
+  private SimpleJsonPath _simplePath;
+  private SimpleJsonPath[] _simpleFourPaths;
+  private Object[] _fourResults;
+
+  public static void main(String[] args)
+      throws Exception {
+    ChainedOptionsBuilder opt =
+        new 
OptionsBuilder().include(BenchmarkJsonPathExtraction.class.getSimpleName()).shouldDoGC(true);
+    new Runner(opt.build()).run();
+  }
+
+  @Setup(Level.Trial)
+  public void setUp() {
+    _path = "early".equals(_fieldPosition) ? "$.user.country" : 
"$.trailer.country";
+    _simplePath = SimpleJsonPath.compile(_path);
+    _simpleFourPaths = new SimpleJsonPath[FOUR_PATHS.length];
+    for (int i = 0; i < FOUR_PATHS.length; i++) {
+      _simpleFourPaths[i] = SimpleJsonPath.compile(FOUR_PATHS[i]);
+    }
+    _fourResults = new Object[FOUR_PATHS.length];
+  }
+
+  @Benchmark
+  public Object jaywayOneColumn() {
+    return PARSE_CONTEXT.parse(JSON).read(_path, NO_PREDICATES);
+  }
+
+  @Benchmark
+  public Object fastOneColumnFullScan() {
+    return FastJsonPathExtractor.extract(JSON, _simplePath, false, false);
+  }
+
+  @Benchmark
+  public Object fastOneColumnEarlyExit() {
+    return FastJsonPathExtractor.extract(JSON, _simplePath, false, true);
+  }
+
+  /// The existing Jayway scalar function (applicability check + String 
coercion).
+  @Benchmark
+  public String jsonPathStringJayway() {
+    return JsonFunctions.jsonPathString(JSON, _path, "");
+  }
+
+  /// The opt-in fast scalar function, full scan (exact parity).
+  @Benchmark
+  public String jsonPathStringFast() {
+    return JsonFunctions.jsonPathStringFast(JSON, _path, "");
+  }
+
+  /// The opt-in fast scalar function, early exit / first match.
+  @Benchmark
+  public String jsonPathStringFirstMatch() {
+    return JsonFunctions.jsonPathStringFirstMatch(JSON, _path, "");
+  }
+
+  @Benchmark
+  public Object jaywayFourColumns() {
+    Object last = null;
+    for (String path : FOUR_PATHS) {
+      last = PARSE_CONTEXT.parse(JSON).read(path, NO_PREDICATES);
+    }
+    return last;
+  }
+
+  @Benchmark
+  public Object fastFourColumnsSeparatePasses() {
+    Object last = null;
+    for (SimpleJsonPath path : _simpleFourPaths) {
+      last = FastJsonPathExtractor.extract(JSON, path, false, false);
+    }
+    return last;
+  }
+
+  @Benchmark
+  public Object[] fastFourColumnsSinglePass() {
+    FastJsonPathExtractor.extract(JSON, _simpleFourPaths, _fourResults, false, 
false);
+    return _fourResults;
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to