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 3b95f424fe4 Speed up JSON-heavy ingestion transforms: non-JSON
fast-path + jsonExtractObject parse-once helper (#18711)
3b95f424fe4 is described below
commit 3b95f424fe4de2d1d6aaf6d0160053c60541ea0c
Author: Xiang Fu <[email protected]>
AuthorDate: Tue Jun 9 12:28:37 2026 -0700
Speed up JSON-heavy ingestion transforms: non-JSON fast-path +
jsonExtractObject parse-once helper (#18711)
* Skip JSON parse for non-JSON input in jsonPath* default-value functions
The default-value overloads of the jsonPath* scalar functions
(jsonPathString/Long/Double(..., default) and jsonPathArrayDefaultEmpty)
parse their argument with Jackson and catch any exception to return the
default. For columns that are sometimes structured JSON and sometimes
plain text (e.g. a log `message` field), every plain-text value throws a
JsonParseException whose fillInStackTrace() dominates the ingestion hot
path. On one realtime log table, filling these stack traces accounted for
roughly 30% of consume-loop CPU.
Add a conservative pre-check (canExtractJsonPath) that returns the
caller's default without invoking the parser when the input is null or a
string that cannot begin a JSON value. Inputs that could begin a JSON
value (object/array/string/number/true/false/null literal) are still
handed to the parser, so behavior - including the exceptions raised by the
throwing two-arg overloads, which are intentionally left unchanged - is
preserved. The change is behavior-preserving: the skipped inputs already
returned the default, just via a thrown-and-caught exception.
Validated with a JMH benchmark of the realtime transform pipeline: the
fix alone raises transform throughput ~1.7x on a JSON log-ingestion
workload (and ~3.3x combined with parse-once transform configs).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Add jsonExtractObject for parse-once JSON ingestion transforms
Add a null-safe scalar that parses a JSON document into a reusable
Map/List once (or passes through an already-parsed container), returning
null - without throwing - for null, scalar, or non-JSON input. It
delegates to the existing jsonStringToListOrMap for strings and
generalizes it to accept Object so it also handles values the decoder or
an upstream transform already parsed.
This enables a "parse-once" transform pattern: materialize an
intermediate object column once and point many JSONPATHSTRING extractions
at it (jsonPath navigates the returned object without re-parsing), instead
of re-parsing the same source document for every extracted field. Because
it never throws, it is safe for columns that are only sometimes JSON
(e.g. a log `message` field) - plain-text rows yield null and fall
through to the downstream default.
Measured on a realtime log-ingestion workload (message JSON ~25% of the
time): parsing `message` once via jsonExtractObject instead of per-field
lifts transform throughput ~7% and end-to-end (transform + index, schema
unchanged) ~9% over re-parsing per field.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
.../common/function/scalar/JsonFunctions.java | 73 +++++++++++++++++-
.../pinot/common/function/JsonFunctionsTest.java | 87 ++++++++++++++++++++++
2 files changed, 159 insertions(+), 1 deletion(-)
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 280ab25fb0b..2a5b3516630 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
@@ -105,6 +105,38 @@ public class JsonFunctions {
return PARSE_CONTEXT.parse(object).read(jsonPath, NO_PREDICATES);
}
+ /**
+ * 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
+ * by the default-value {@code jsonPath*} overloads to skip parsing
plain-text input (e.g. raw log lines) that
+ * would otherwise throw a {@link com.jayway.jsonpath.InvalidJsonException}
whose {@code fillInStackTrace()}
+ * dominates the hot ingestion path. It is intentionally conservative: any
string that could begin a JSON value
+ * (object, array, string, number, or a {@code true}/{@code false}/{@code
null} literal) is still handed to the
+ * parser, so the parser's behavior - including any exception it raises - is
unchanged for those inputs. Returning
+ * the caller's default for the skipped inputs is equivalent to the prior
behavior, where the parse exception was
+ * caught and the default returned.
+ */
+ private static boolean canExtractJsonPath(@Nullable Object object) {
+ if (object == null) {
+ return false;
+ }
+ if (!(object instanceof String)) {
+ // Already-parsed Map/List/etc. - handled by jsonPath() directly without
re-parsing.
+ return true;
+ }
+ String s = (String) object;
+ for (int i = 0, n = s.length(); i < n; i++) {
+ char c = s.charAt(i);
+ if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
+ continue;
+ }
+ // First non-whitespace character of any valid JSON value.
+ return c == '{' || c == '[' || c == '"' || c == '-' || (c >= '0' && c <=
'9') || c == 't' || c == 'f'
+ || c == 'n';
+ }
+ return false; // empty / all-whitespace is not valid JSON
+ }
+
/**
* Extract object array based on Json path
*/
@@ -119,8 +151,11 @@ public class JsonFunctions {
@ScalarFunction(nullableParameters = true)
public static Object[] jsonPathArrayDefaultEmpty(@Nullable Object object,
String jsonPath) {
+ if (!canExtractJsonPath(object)) {
+ return EMPTY;
+ }
try {
- Object[] result = object == null ? null : jsonPathArray(object,
jsonPath);
+ Object[] result = jsonPathArray(object, jsonPath);
return result == null ? EMPTY : result;
} catch (Exception e) {
return EMPTY;
@@ -172,6 +207,9 @@ public class JsonFunctions {
*/
@ScalarFunction(nullableParameters = true)
public static String jsonPathString(@Nullable Object object, String
jsonPath, String defaultValue) {
+ if (!canExtractJsonPath(object)) {
+ return defaultValue;
+ }
try {
Object jsonValue = jsonPath(object, jsonPath);
if (jsonValue instanceof String) {
@@ -196,6 +234,9 @@ public class JsonFunctions {
*/
@ScalarFunction(nullableParameters = true)
public static long jsonPathLong(@Nullable Object object, String jsonPath,
long defaultValue) {
+ if (!canExtractJsonPath(object)) {
+ return defaultValue;
+ }
try {
Object jsonValue = jsonPath(object, jsonPath);
if (jsonValue == null) {
@@ -223,6 +264,9 @@ public class JsonFunctions {
*/
@ScalarFunction(nullableParameters = true)
public static double jsonPathDouble(@Nullable Object object, String
jsonPath, double defaultValue) {
+ if (!canExtractJsonPath(object)) {
+ return defaultValue;
+ }
try {
Object jsonValue = jsonPath(object, jsonPath);
if (jsonValue == null) {
@@ -323,6 +367,33 @@ public class JsonFunctions {
return null;
}
+ /**
+ * Parse a JSON document into a reusable {@code Map}/{@code List}, or pass
through an already-parsed container,
+ * returning {@code null} for null, scalar, or non-JSON input <b>without
throwing</b>.
+ * <p>Intended for "parse-once" ingestion transforms: materialize an
intermediate object column once and point
+ * multiple {@code jsonPath*} extractions at it, instead of re-parsing the
same source document for every extracted
+ * field. {@link #jsonPath} navigates the returned object directly (no
re-parse), and because this never throws it
+ * is safe for columns that are sometimes structured JSON and sometimes
plain text (e.g. a log {@code message}
+ * field) - the plain-text rows simply yield {@code null} and fall through
to the default of the downstream
+ * extraction. Example transform configs:
+ * <pre>
+ * {"columnName": "message_obj", "transformFunction":
"jsonExtractObject(message)"}
+ * {"columnName": "level", "transformFunction":
"JSONPATHSTRING(message_obj, '$.level', null)"}
+ * </pre>
+ */
+ @Nullable
+ @ScalarFunction(nullableParameters = true)
+ public static Object jsonExtractObject(@Nullable Object object) {
+ if (object instanceof String) {
+ return jsonStringToListOrMap((String) object);
+ }
+ if (object instanceof Map || object instanceof List || object instanceof
Object[]) {
+ // Already parsed (e.g. a nested object surfaced by the decoder or an
upstream transform) - reuse as-is.
+ return object;
+ }
+ return null;
+ }
+
private static void setValuesToMap(String keyColumnName, String
valueColumnName, Object obj,
Map<String, String> result) {
if (obj instanceof Map) {
diff --git
a/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java
b/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java
index 39ea369c710..96d83b95119 100644
---
a/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java
+++
b/pinot-common/src/test/java/org/apache/pinot/common/function/JsonFunctionsTest.java
@@ -34,6 +34,7 @@ import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertFalse;
import static org.testng.Assert.assertNull;
+import static org.testng.Assert.assertSame;
import static org.testng.Assert.assertTrue;
@@ -156,6 +157,92 @@ public class JsonFunctionsTest {
assertEquals(JsonFunctions.jsonPathString("{\"foo\": \"null\"}", "$.foo",
"default"), "null");
}
+ /**
+ * The default-value {@code jsonPath*} overloads return the caller's default
for input that cannot begin a JSON
+ * value (plain text, null, empty) without invoking the parser - this avoids
the {@code fillInStackTrace()} cost
+ * of a thrown-and-caught {@link InvalidJsonException} on the ingestion hot
path. Behavior is unchanged for valid
+ * JSON and for any input that could begin a JSON value (which is still
handed to the parser).
+ */
+ @Test
+ public void testJsonPathDefaultVariantsSkipNonJson() {
+ // Plain-text input (e.g. a raw log line) -> default, no exception thrown.
+ assertEquals(JsonFunctions.jsonPathString("INFO 2026-06-08 request done",
"$.level", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathString("INFO 2026-06-08 request done",
"$", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathLong("ERROR boom code", "$.code", -1L),
-1L);
+ assertEquals(JsonFunctions.jsonPathDouble("WARN slow request",
"$.latency", -1.0), -1.0, 0.0);
+ assertEquals(JsonFunctions.jsonPathArrayDefaultEmpty("plain text line",
"$.items").length, 0);
+
+ // null input -> default.
+ assertEquals(JsonFunctions.jsonPathString(null, "$.level", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathLong(null, "$.code", -1L), -1L);
+ assertEquals(JsonFunctions.jsonPathArrayDefaultEmpty(null,
"$.items").length, 0);
+
+ // Empty / whitespace-only input -> default.
+ assertEquals(JsonFunctions.jsonPathString("", "$.x", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathString(" ", "$.x", "def"), "def");
+
+ // Valid JSON object/array is still extracted, including with leading
whitespace.
+ assertEquals(JsonFunctions.jsonPathString("{\"level\":\"info\"}",
"$.level", "def"), "info");
+ assertEquals(JsonFunctions.jsonPathString(" \n {\"level\":\"info\"}",
"$.level", "def"), "info");
+ assertEquals(JsonFunctions.jsonPathString("[{\"k\":\"v\"}]", "$[0].k",
"def"), "v");
+
+ // Inputs that could begin a JSON value are still handed to the parser, so
behavior is unchanged:
+ // "not json" begins with 'n' (the `null` literal) -> parsed, fails,
default returned.
+ assertEquals(JsonFunctions.jsonPathString("not json", "$.x", "def"),
"def");
+ // Bare JSON scalars are parsed and returned for the whole-document path
(preserved behavior).
+ assertEquals(JsonFunctions.jsonPathString("12345", "$", "def"), "12345");
+ assertEquals(JsonFunctions.jsonPathString("\"hello\"", "$", "def"),
"hello");
+
+ // Inputs the fast-path skips that the parser also rejected before ->
default (equivalence preserved at the
+ // exact char boundary: these do not begin with {, [, ", -, a digit, or a
true/false/null literal).
+ assertEquals(JsonFunctions.jsonPathString("+5", "$", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathString(".5", "$", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathString("NaN", "$", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathString("Infinity", "$", "def"), "def");
+ assertEquals(JsonFunctions.jsonPathString("'single quoted'", "$.x",
"def"), "def");
+ }
+
+ /**
+ * {@code jsonExtractObject} parses a JSON document once into a reusable
Map/List (or passes through an
+ * already-parsed container), returns null without throwing for
null/scalar/non-JSON input, and the returned object
+ * is navigable by {@code jsonPath*} with results identical to parsing the
raw string - enabling parse-once
+ * transform configs.
+ */
+ @Test
+ public void testJsonExtractObject()
+ throws Exception {
+ // JSON object -> reusable Map, navigable by jsonPath without re-parsing.
+ Object obj =
JsonFunctions.jsonExtractObject("{\"level\":\"info\",\"log\":{\"msg\":\"hi\"}}");
+ assertTrue(obj instanceof Map);
+ assertEquals(JsonFunctions.jsonPathString(obj, "$.log.msg", "def"), "hi");
+ assertEquals(JsonFunctions.jsonPathString(obj, "$.level", "def"), "info");
+
+ // JSON array -> reusable List.
+ Object arr = JsonFunctions.jsonExtractObject("[{\"k\":\"v\"}]");
+ assertTrue(arr instanceof List);
+ assertEquals(JsonFunctions.jsonPathString(arr, "$[0].k", "def"), "v");
+
+ // null / scalar / plain-text / empty -> null, no exception.
+ assertNull(JsonFunctions.jsonExtractObject(null));
+ assertNull(JsonFunctions.jsonExtractObject("INFO 2026-06-08 plain text log
line"));
+ assertNull(JsonFunctions.jsonExtractObject("12345"));
+ assertNull(JsonFunctions.jsonExtractObject(""));
+
+ // Already-parsed container -> passed through unchanged (Map and
Object[]), still navigable by jsonPath.
+ Map<String, Object> map = Map.of("a", "b");
+ assertEquals(JsonFunctions.jsonExtractObject(map), map);
+ Object[] preParsed = new Object[]{Map.of("k", "v")};
+ assertSame(JsonFunctions.jsonExtractObject(preParsed), preParsed);
+ assertEquals(JsonFunctions.jsonPathString(preParsed, "$[0].k", "def"),
"v");
+
+ // Parse-once equivalence: extracting through the parsed object matches
extracting from the raw string.
+ String json =
"{\"labels\":{\"service_name\":\"svc-1\",\"environment\":\"prod\"},\"id\":\"abc\"}";
+ Object parsed = JsonFunctions.jsonExtractObject(json);
+ assertEquals(JsonFunctions.jsonPathString(parsed, "$.labels.service_name",
"def"),
+ JsonFunctions.jsonPathString(json, "$.labels.service_name", "def"));
+ assertEquals(JsonFunctions.jsonPathString(parsed, "$.id", "def"), "abc");
+ }
+
@Test
public void testJsonFunctionExtractingArray()
throws JsonProcessingException {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]