Copilot commented on code in PR #19223:
URL: https://github.com/apache/pinot/pull/19223#discussion_r3769293921
##########
pinot-common/src/main/java/org/apache/pinot/common/function/scalar/JsonFunctions.java:
##########
@@ -129,6 +130,25 @@ private static Object fastJsonPath(Object object, String
jsonPath, boolean useBi
return jsonPath(object, jsonPath);
}
+ /// Resolves a simple path over a JSON string with Fory's streaming reader.
Complex paths, already-parsed inputs,
+ /// container results, and Fory extraction failures use the existing Jayway
implementation so the experimental
+ /// functions below retain the current behavior outside Fory's supported
envelope.
+ @Nullable
+ private static Object foryJsonPath(Object object, String jsonPath) {
+ SimpleJsonPath simpleJsonPath = SimpleJsonPath.compile(jsonPath);
+ if (!(object instanceof String) || simpleJsonPath == null) {
+ return jsonPath(object, jsonPath);
+ }
Review Comment:
`foryJsonPath()` compiles `SimpleJsonPath` even when the input is not a
`String` (e.g., already-parsed Map/List input), then immediately falls back to
Jayway. This adds unnecessary work on the fallback path and is inconsistent
with `fastJsonPath()` which only compiles for String inputs.
##########
pinot-common/src/main/java/org/apache/pinot/common/function/ForyJsonPathExtractor.java:
##########
@@ -0,0 +1,439 @@
+/**
+ * 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.StreamReadConstraints;
+import java.util.concurrent.atomic.AtomicBoolean;
+import javax.annotation.Nullable;
+import org.apache.fory.json.ForyJson;
+import org.apache.fory.json.codec.JsonValueCodec;
+import org.apache.fory.json.reader.JsonReader;
+import org.apache.fory.json.reader.Latin1JsonReader;
+import org.apache.fory.json.reader.Utf16JsonReader;
+import org.apache.fory.json.reader.Utf8JsonReader;
+import org.apache.fory.json.writer.StringJsonWriter;
+import org.apache.fory.json.writer.Utf8JsonWriter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+
+/// Shared Fory JSON parser for opt-in JSON-path implementations.
+///
+/// Streaming extraction uses one single-state parser per worker thread to
avoid shared-pool contention. Initialization
+/// or runtime linkage failures permanently disable the optional path,
allowing callers to fall back to
+/// Jackson/Jayway. Jackson's default scalar token limits are enforced while
walking the document, and inputs outside
+/// Fory's safe nesting depth fall back to the reference parser.
+public final class ForyJsonPathExtractor {
+ private static final Logger LOGGER =
LoggerFactory.getLogger(ForyJsonPathExtractor.class);
+ private static final StreamReadConstraints JACKSON_CONSTRAINTS =
StreamReadConstraints.defaults();
+ private static final ThreadLocal<PathContext> PATH_CONTEXT =
ThreadLocal.withInitial(PathContext::new);
+ private static final AtomicBoolean UNAVAILABLE_WARNING_LOGGED = new
AtomicBoolean();
+
+ private ForyJsonPathExtractor() {
+ }
+
+ private static final class Holder {
+ private static volatile boolean _streamingAvailable;
+ private static final ThreadLocal<ForyJson> STREAMING_PARSER =
ThreadLocal.withInitial(() -> {
+ if (!_streamingAvailable) {
+ throw new IllegalStateException("Fory JSON is unavailable");
+ }
+ ForyJson parser = buildStreamingParser();
+ if (parser == null) {
+ _streamingAvailable = false;
+ throw new IllegalStateException("Fory JSON is unavailable");
+ }
+ return parser;
+ });
+
+ static {
+ ForyJson parser = buildStreamingParser();
+ _streamingAvailable = parser != null;
+ if (parser != null) {
+ STREAMING_PARSER.set(parser);
+ }
+ }
+
+ private Holder() {
+ }
+
+ @Nullable
+ private static ForyJson buildStreamingParser() {
+ try {
+ return
ForyJson.builder().withCodegen(false).withAsyncCompilation(false).withConcurrencyLevel(1)
+ .registerCodec(PathResult.class, PathCodec.INSTANCE).build();
+ } catch (RuntimeException | LinkageError e) {
+ logUnavailable(e);
+ return null;
+ }
+ }
+ }
+
+ /// Returns whether the optional Fory runtime initialized successfully.
+ public static boolean isAvailable() {
+ return Holder._streamingAvailable;
+ }
+
+ /// Extracts a simple path with Fory's streaming reader without
materializing the complete JSON tree.
+ ///
+ /// Unrelated values are still fully consumed so malformed input and
duplicate-key last-wins behavior match the
+ /// reference parser. Jackson's nesting, field-name, string, and number
limits are checked while scanning. Callers
+ /// should retry with the reference parser when this method throws.
+ @Nullable
+ public static Object extract(String json, SimpleJsonPath path) {
+ if ((JACKSON_CONSTRAINTS.hasMaxDocumentLength()
+ && json.length() > JACKSON_CONSTRAINTS.getMaxDocumentLength())
+ || (JACKSON_CONSTRAINTS.hasMaxTokenCount() &&
requiresJacksonFallback(json))) {
+ throw new IllegalArgumentException("JSON document requires Jackson
constraint validation");
+ }
Review Comment:
`requiresJacksonFallback(json)` also checks max nesting depth / token sizes,
but it is only invoked when `JACKSON_CONSTRAINTS.hasMaxTokenCount()` is true.
That makes the nesting-depth and length checks inside `requiresJacksonFallback`
effectively unreachable under the default Jackson constraints configuration,
and contradicts the class comment about enforcing Jackson constraints while
scanning.
##########
pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonExtractScalarQuery.java:
##########
@@ -0,0 +1,218 @@
+/**
+ * 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 java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.pinot.common.request.context.ExpressionContext;
+import org.apache.pinot.common.request.context.LiteralContext;
+import org.apache.pinot.core.common.BlockValSet;
+import org.apache.pinot.core.operator.ColumnContext;
+import org.apache.pinot.core.operator.blocks.ValueBlock;
+import org.apache.pinot.core.operator.transform.TransformResultMetadata;
+import org.apache.pinot.core.operator.transform.function.BaseTransformFunction;
+import
org.apache.pinot.core.operator.transform.function.JsonExtractScalarTransformFunction;
+import
org.apache.pinot.core.operator.transform.function.LiteralTransformFunction;
+import org.apache.pinot.core.operator.transform.function.TransformFunction;
+import org.apache.pinot.spi.data.FieldSpec.DataType;
+import org.openjdk.jmh.annotations.Benchmark;
+import org.openjdk.jmh.annotations.BenchmarkMode;
+import org.openjdk.jmh.annotations.Fork;
+import org.openjdk.jmh.annotations.Measurement;
+import org.openjdk.jmh.annotations.Mode;
+import org.openjdk.jmh.annotations.OperationsPerInvocation;
+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.Options;
+import org.openjdk.jmh.runner.options.OptionsBuilder;
+
+
+/// Measures the actual query-side `jsonExtractScalar*` ValueBlock loop. Each
invocation processes a 128-row block;
+/// JMH normalizes throughput and allocation to one row via
[OperationsPerInvocation]. Input projection is represented
+/// by a pre-materialized String array so the comparison isolates JSON
extraction, type coercion, and result writing.
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Fork(1)
+@Warmup(iterations = 3, time = 2)
+@Measurement(iterations = 5, time = 3)
+@State(Scope.Thread)
+public class BenchmarkJsonExtractScalarQuery {
+ private static final int BLOCK_ROWS = 128;
+ private static final TransformResultMetadata STRING_METADATA =
+ new TransformResultMetadata(DataType.STRING, true, false);
+ private static final String BASE_JSON = "{"
+ + "\"earlyMetric\":17,"
+ +
"\"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\":129.0},{\"sku\":\"C3\",\"qty\":4,\"price\":3.5}],"
+ + "\"currency\":\"USD\",\"coupon\":null},"
+ +
"\"device\":{\"os\":\"macOS\",\"version\":\"14.5\",\"browser\":\"Chrome\","
+ + "\"screen\":{\"width\":1728,\"height\":1117}},"
+ + "\"geo\":{\"city\":\"San
Francisco\",\"region\":\"CA\",\"country\":\"US\","
+ + "\"coordinates\":[-122.4194,37.7749]},"
+ +
"\"attributes\":{\"campaign\":\"summer-sale\",\"referrer\":\"search\",\"experiment\":\"checkout-v2\"},"
+ + "\"flags\":[\"returning\",\"subscribed\",\"beta\"],"
+ + "\"lateMetric\":19} ";
+
+ @Param({"early", "late"})
+ private String _fieldPosition;
+
+ @Param({"700", "8192", "65536"})
+ private int _documentBytes;
+
+ private ValueBlock _valueBlock;
+ private JsonExtractScalarTransformFunction _jayway;
+ private JsonExtractScalarTransformFunction _fast;
+ private JsonExtractScalarTransformFunction _firstMatch;
+ private JsonExtractScalarTransformFunction _fory;
+
+ @Setup
+ public void setUp() {
+ String json = buildJson(_documentBytes);
+ String[] jsonRows = new String[BLOCK_ROWS];
+ Arrays.fill(jsonRows, json);
+ TransformFunction input = new StringArrayTransformFunction(jsonRows);
+ String path = "early".equals(_fieldPosition) ? "$.earlyMetric" :
"$.lateMetric";
+ List<TransformFunction> arguments = List.of(input, literal(path),
literal("LONG"));
+
+ _valueBlock = new FixedValueBlock(BLOCK_ROWS);
+ _jayway = new JsonExtractScalarTransformFunction();
+ _fast = new JsonExtractScalarTransformFunction.Fast();
+ _firstMatch = new JsonExtractScalarTransformFunction.FirstMatch();
+ _fory = new JsonExtractScalarTransformFunction.Fory();
+ for (JsonExtractScalarTransformFunction function : List.of(_jayway, _fast,
_firstMatch, _fory)) {
+ function.init(arguments, Map.<String, ColumnContext>of(), false);
+ long expected = "early".equals(_fieldPosition) ? 17L : 19L;
+ long[] values = function.transformToLongValuesSV(_valueBlock);
+ if (values.length < BLOCK_ROWS || values[0] != expected ||
values[BLOCK_ROWS - 1] != expected) {
+ throw new IllegalStateException(function.getName() + " produced an
unexpected query result");
+ }
+ }
+ }
+
+ private static LiteralTransformFunction literal(String value) {
+ return new LiteralTransformFunction(new LiteralContext(DataType.STRING,
value));
+ }
+
+ private static String buildJson(int targetBytes) {
+ if (BASE_JSON.length() >= targetBytes) {
+ return BASE_JSON;
+ }
+ String marker = "\"lateMetric\":";
+ int markerOffset = BASE_JSON.indexOf(marker);
+ String paddingPrefix = "\"padding\":\"";
+ String paddingSuffix = "\",";
+ int paddingLength = targetBytes - BASE_JSON.length() -
paddingPrefix.length() - paddingSuffix.length();
+ return BASE_JSON.substring(0, markerOffset) + paddingPrefix +
"x".repeat(paddingLength) + paddingSuffix
+ + BASE_JSON.substring(markerOffset);
Review Comment:
`buildJson(int targetBytes)` can compute a negative `paddingLength` when
`targetBytes` is slightly larger than `BASE_JSON.length()` but smaller than
`BASE_JSON.length() + paddingPrefix.length() + paddingSuffix.length()`, causing
`"x".repeat(paddingLength)` to throw. `BenchmarkJsonPathExtraction#buildJson`
already guards this case; this benchmark should do the same to keep the helper
robust if the base JSON changes or params are adjusted.
##########
pinot-core/src/main/java/org/apache/pinot/core/operator/transform/function/JsonExtractScalarTransformFunction.java:
##########
@@ -72,6 +74,10 @@
/// keys to the first non-null occurrence and does not validate malformed
content after the resolved value. Use it
/// only for well-formed, duplicate-free JSON. `Fast` scans the full root
value and retains Jayway's last-key-wins
/// and malformed-document behavior; see [FastJsonPathExtractor] for one
documented unaddressed-value edge case.
+/// `jsonExtractScalarFory` is experimental and must be selected explicitly.
It accelerates simple paths over
+/// `STRING` input for scalar result types other than `STRING` and
`BIG_DECIMAL`. `BYTES` input, complex paths,
+/// containers / array result types, precision-sensitive results, deeply
nested documents, and Fory failures use
+/// Jayway. Its name, supported envelope, and implementation can change while
the integration is evaluated.
Review Comment:
The Javadoc says `jsonExtractScalarFory` accelerates scalar result types
other than `STRING` and `BIG_DECIMAL`, but the implementation enables the Fory
extractor for `STRING` results as well (it only gates on `useBigDecimal`). This
makes the doc misleading for callers trying to understand when the Fory path is
actually used.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]