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 bff70e12b82 Improve JSON decoder selected-field performance (#19127)
bff70e12b82 is described below
commit bff70e12b82caa40a1a9f9308904e51b96ef656a
Author: Xiang Fu <[email protected]>
AuthorDate: Sun Aug 2 15:36:36 2026 -0700
Improve JSON decoder selected-field performance (#19127)
* Improve JSON decoder selected-field performance
* Address JSON decoder review feedback
* Fix benchmark Javadoc style
* Read top-level scalars directly off the parser in the direct decode path
Scalar values in the streaming decode loop previously went through
ObjectReader.readValue, which allocates a fresh DeserializationContext
per value. Read them straight off the JsonParser instead, mirroring
databind's untyped materialization: getNumberValue() preserves the
binary formats' Float and widens oversized ints through the shared
JSONRecordExtractor.convert contract; embedded objects (byte[]) pass
through unchanged. Containers still materialize through databind.
Measured with BenchmarkJsonParsing (-prof gc, JDK 25): ~72 B/op saved
per top-level scalar field on all-field decoding (medium payload:
3177 -> 2529 B/op, -20%); selected-field workloads are unchanged.
Also adds decoder-level coverage for binary scalar shapes (Float never
upcast to Double, byte[] pass-through) and for boolean/null values
through the direct path, including explicit JSON null overwriting a
stale value in a reused row.
* Clarify direct JSON parser contract
Document destination mutation semantics for direct parsing and add decoder
coverage proving sliced payload offsets and lengths are honored.
---
pinot-perf/pom.xml | 4 +
.../apache/pinot/perf/BenchmarkJsonParsing.java | 78 +++++++++++++++++-
.../inputformat/json/JSONMessageDecoder.java | 11 ++-
.../inputformat/json/JSONRecordExtractor.java | 2 +-
.../json/format/AutoDetectPayloadParser.java | 11 +++
.../json/format/JacksonPayloadParser.java | 95 +++++++++++++++++++++-
.../inputformat/json/format/JsonPayloadParser.java | 19 +++++
.../json/format/PostgresJsonbPayloadParser.java | 27 +++++-
.../json/format/TextJsonPayloadParser.java | 15 ++--
.../json/JSONMessageDecoderBinaryTest.java | 18 ++++
.../inputformat/json/JSONMessageDecoderTest.java | 91 +++++++++++++++++++++
11 files changed, 354 insertions(+), 17 deletions(-)
diff --git a/pinot-perf/pom.xml b/pinot-perf/pom.xml
index b0e8ed9efcb..68fd9816503 100644
--- a/pinot-perf/pom.xml
+++ b/pinot-perf/pom.xml
@@ -58,6 +58,10 @@
<groupId>org.apache.pinot</groupId>
<artifactId>pinot-protobuf</artifactId>
</dependency>
+ <dependency>
+ <groupId>org.apache.pinot</groupId>
+ <artifactId>pinot-json</artifactId>
+ </dependency>
<dependency>
<groupId>org.apache.pinot</groupId>
diff --git
a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonParsing.java
b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonParsing.java
index 4c8714e9fad..43f5a67043d 100644
--- a/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonParsing.java
+++ b/pinot-perf/src/main/java/org/apache/pinot/perf/BenchmarkJsonParsing.java
@@ -25,7 +25,11 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Random;
+import java.util.Set;
import java.util.concurrent.TimeUnit;
+import org.apache.pinot.plugin.inputformat.json.JSONMessageDecoder;
+import org.apache.pinot.plugin.inputformat.json.JSONRecordExtractor;
+import org.apache.pinot.spi.data.readers.GenericRow;
import org.apache.pinot.spi.utils.JsonUtils;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
@@ -80,9 +84,18 @@ public class BenchmarkJsonParsing {
private List<byte[]> _jsonPayloads;
private int _currentIndex = 0;
+ private JSONMessageDecoder _allFieldsDecoder;
+ private JSONMessageDecoder _selectedFieldsDecoder;
+ private JSONRecordExtractor _allFieldsExtractor;
+ private JSONRecordExtractor _selectedFieldsExtractor;
+ private GenericRow _mapAllFieldsRow;
+ private GenericRow _directAllFieldsRow;
+ private GenericRow _mapSelectedFieldsRow;
+ private GenericRow _directSelectedFieldsRow;
@Setup(Level.Trial)
- public void setUp() {
+ public void setUp()
+ throws Exception {
_jsonPayloads = new ArrayList<>(NUM_MESSAGES);
Random random = new Random(42);
@@ -90,6 +103,20 @@ public class BenchmarkJsonParsing {
String json = generateJsonPayload(_payloadType, random, i);
_jsonPayloads.add(json.getBytes(StandardCharsets.UTF_8));
}
+
+ Set<String> selectedFields = selectedFields(_payloadType);
+ _allFieldsDecoder = new JSONMessageDecoder();
+ _allFieldsDecoder.init(Map.of(), null, "benchmark");
+ _selectedFieldsDecoder = new JSONMessageDecoder();
+ _selectedFieldsDecoder.init(Map.of(), selectedFields, "benchmark");
+ _allFieldsExtractor = new JSONRecordExtractor();
+ _allFieldsExtractor.init(null, null);
+ _selectedFieldsExtractor = new JSONRecordExtractor();
+ _selectedFieldsExtractor.init(selectedFields, null);
+ _mapAllFieldsRow = new GenericRow();
+ _directAllFieldsRow = new GenericRow();
+ _mapSelectedFieldsRow = new GenericRow();
+ _directSelectedFieldsRow = new GenericRow();
}
/// Generates different types of JSON payloads to simulate real-world
streaming data.
@@ -229,6 +256,55 @@ public class BenchmarkJsonParsing {
return result;
}
+ /// BASELINE DECODER: materialize the top-level map, then copy all fields
into GenericRow.
+ @Benchmark
+ public GenericRow mapThenExtractAllFields()
+ throws IOException {
+ _mapAllFieldsRow.clear();
+ byte[] payload = getNextPayload();
+ return _allFieldsExtractor.extract(JsonUtils.bytesToMap(payload, 0,
payload.length), _mapAllFieldsRow);
+ }
+
+ /// OPTIMIZED DECODER: stream all top-level values directly into GenericRow.
+ @Benchmark
+ public GenericRow directToGenericRowAllFields() {
+ _directAllFieldsRow.clear();
+ byte[] payload = getNextPayload();
+ return _allFieldsDecoder.decode(payload, _directAllFieldsRow);
+ }
+
+ /// BASELINE DECODER: materialize the full top-level map, then copy selected
fields into GenericRow.
+ @Benchmark
+ public GenericRow mapThenExtractSelectedFields()
+ throws IOException {
+ _mapSelectedFieldsRow.clear();
+ byte[] payload = getNextPayload();
+ return _selectedFieldsExtractor.extract(JsonUtils.bytesToMap(payload, 0,
payload.length), _mapSelectedFieldsRow);
+ }
+
+ /// OPTIMIZED DECODER: materialize selected values only and skip unselected
containers.
+ @Benchmark
+ public GenericRow directToGenericRowSelectedFields() {
+ _directSelectedFieldsRow.clear();
+ byte[] payload = getNextPayload();
+ return _selectedFieldsDecoder.decode(payload, _directSelectedFieldsRow);
+ }
+
+ private static Set<String> selectedFields(String payloadType) {
+ switch (payloadType) {
+ case "small":
+ return Set.of("id", "status");
+ case "medium":
+ return Set.of("eventId", "userId", "timestamp", "country");
+ case "large":
+ return Set.of("eventId", "timestamp");
+ case "nested":
+ return Set.of("order");
+ default:
+ return Set.of("id");
+ }
+ }
+
// Helper methods for generating realistic test data
private static String randomEventType(Random random) {
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoder.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoder.java
index 5a23281b6ea..75ce66d50cb 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoder.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoder.java
@@ -21,6 +21,7 @@ package org.apache.pinot.plugin.inputformat.json;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.Set;
+import org.apache.commons.collections4.CollectionUtils;
import org.apache.pinot.plugin.inputformat.json.format.JsonPayloadFormat;
import org.apache.pinot.plugin.inputformat.json.format.JsonPayloadParser;
import org.apache.pinot.spi.data.readers.GenericRow;
@@ -53,6 +54,8 @@ public class JSONMessageDecoder implements
StreamMessageDecoder<byte[]> {
"org.apache.pinot.plugin.inputformat.json.JSONRecordExtractor";
private RecordExtractor<Map<String, Object>> _jsonRecordExtractor;
+ private Set<String> _fieldsToRead;
+ private boolean _usesDefaultRecordExtractor;
// For AUTO this resolves the concrete format per message; otherwise it is
the pinned format's parser.
private JsonPayloadParser _parser;
@@ -70,6 +73,10 @@ public class JSONMessageDecoder implements
StreamMessageDecoder<byte[]> {
}
_jsonRecordExtractor =
PluginManager.get().createInstance(recordExtractorClass);
_jsonRecordExtractor.init(fieldsToRead, null);
+ _fieldsToRead = CollectionUtils.isNotEmpty(fieldsToRead) ?
Set.copyOf(fieldsToRead) : null;
+ // Direct parsing implements JSONRecordExtractor's conversion contract and
bypasses extract(). Require the
+ // exact default class so a configured extractor or subclass cannot lose
custom extraction behavior.
+ _usesDefaultRecordExtractor = _jsonRecordExtractor.getClass() ==
JSONRecordExtractor.class;
_parser = JsonPayloadFormat.fromConfig(jsonFormat).getParser();
}
@@ -81,7 +88,9 @@ public class JSONMessageDecoder implements
StreamMessageDecoder<byte[]> {
@Override
public GenericRow decode(byte[] payload, int offset, int length, GenericRow
destination) {
try {
- // Parse directly to Map, avoiding an intermediate JsonNode
representation for better performance.
+ if (_usesDefaultRecordExtractor && _parser.parse(payload, offset,
length, destination, _fieldsToRead)) {
+ return destination;
+ }
Map<String, Object> jsonMap = _parser.parse(payload, offset, length);
return _jsonRecordExtractor.extract(jsonMap, destination);
} catch (Exception e) {
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONRecordExtractor.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONRecordExtractor.java
index d2fbe09da64..8ada5adbf99 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONRecordExtractor.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONRecordExtractor.java
@@ -61,7 +61,7 @@ public class JSONRecordExtractor extends
BaseRecordExtractor<Map<String, Object>
/// Walks a non-null Jackson-parsed value and produces the contract shape:
`BigDecimal` for `BigInteger`
/// (oversized ints), `Object[]` for JSON arrays, `Map<String, Object>` for
JSON objects, pass-through for
/// the other Jackson scalar types (`Boolean`, `Integer`, `Long`, `Double`,
`String`).
- private static Object convert(Object value) {
+ public static Object convert(Object value) {
// BigInteger widens (Pinot has no BigInteger type)
if (value instanceof BigInteger) {
return new BigDecimal((BigInteger) value);
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/AutoDetectPayloadParser.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/AutoDetectPayloadParser.java
index af1f6783c20..c73d84774e2 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/AutoDetectPayloadParser.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/AutoDetectPayloadParser.java
@@ -19,6 +19,9 @@
package org.apache.pinot.plugin.inputformat.json.format;
import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.pinot.spi.data.readers.GenericRow;
/// Resolves the concrete parser per message from the payload's leading magic
/ version bytes, then delegates.
@@ -37,4 +40,12 @@ class AutoDetectPayloadParser implements JsonPayloadParser {
throws Exception {
return JsonPayloadFormat.detectParser(payload, offset,
length).parse(payload, offset, length);
}
+
+ @Override
+ public boolean parse(byte[] payload, int offset, int length, GenericRow
destination,
+ @Nullable Set<String> fields)
+ throws Exception {
+ return JsonPayloadFormat.detectParser(payload, offset, length)
+ .parse(payload, offset, length, destination, fields);
+ }
}
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JacksonPayloadParser.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JacksonPayloadParser.java
index 711c55e2bec..e3dbc4d2d07 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JacksonPayloadParser.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JacksonPayloadParser.java
@@ -19,9 +19,16 @@
package org.apache.pinot.plugin.inputformat.json.format;
import com.fasterxml.jackson.core.JsonFactory;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectReader;
+import java.io.IOException;
import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.pinot.plugin.inputformat.json.JSONRecordExtractor;
+import org.apache.pinot.spi.data.readers.GenericRow;
import org.apache.pinot.spi.utils.JsonUtils;
@@ -31,15 +38,101 @@ import org.apache.pinot.spi.utils.JsonUtils;
/// The [ObjectReader] is immutable and thread-safe, so a single instance is
shared across all decode calls.
abstract class JacksonPayloadParser implements JsonPayloadParser {
+ private final JsonFactory _factory;
private final ObjectReader _mapReader;
+ private final ObjectReader _valueReader;
JacksonPayloadParser(JsonFactory factory) {
- _mapReader = new
ObjectMapper(factory).readerFor(JsonUtils.MAP_TYPE_REFERENCE);
+ this(new ObjectMapper(factory).reader());
+ }
+
+ /// The reader must use default deserialization features: [#readValue]'s
scalar fast path bypasses databind,
+ /// so features like `USE_BIG_DECIMAL_FOR_FLOATS` would apply to containers
but silently not to top-level
+ /// scalars. Every current caller passes a default-configured reader.
+ JacksonPayloadParser(ObjectReader reader) {
+ _factory = reader.getFactory();
+ _mapReader = reader.forType(JsonUtils.MAP_TYPE_REFERENCE);
+ _valueReader = reader.forType(Object.class);
}
@Override
public Map<String, Object> parse(byte[] payload, int offset, int length)
throws Exception {
+ return parseMap(payload, offset, length);
+ }
+
+ protected final Map<String, Object> parseMap(byte[] payload, int offset, int
length)
+ throws Exception {
return _mapReader.readValue(payload, offset, length);
}
+
+ /// Streams the top-level object fields into the row. Nested selected values
still use Jackson's normal
+ /// materialization and are converted by [JSONRecordExtractor], but the
top-level map is never allocated.
+ /// Unselected containers are skipped without materializing their contents.
+ @Override
+ public boolean parse(byte[] payload, int offset, int length, GenericRow
destination,
+ @Nullable Set<String> fields)
+ throws Exception {
+ if (fields != null) {
+ // Match JSONRecordExtractor's missing-field behavior and overwrite
values left in a reused row.
+ for (String field : fields) {
+ destination.putValue(field, null);
+ }
+ }
+ try (JsonParser parser = _factory.createParser(payload, offset, length)) {
+ if (parser.nextToken() != JsonToken.START_OBJECT) {
+ throw new IllegalArgumentException("Top-level JSON value must be an
object");
+ }
+ JsonToken token;
+ while ((token = parser.nextToken()) != JsonToken.END_OBJECT) {
+ if (token == null) {
+ throw new IllegalArgumentException("Unexpected end of JSON object");
+ }
+ if (token != JsonToken.FIELD_NAME) {
+ throw new IllegalArgumentException("Expected a JSON object field,
found: " + token);
+ }
+ String fieldName = parser.currentName();
+ JsonToken valueToken = parser.nextToken();
+ if (valueToken == null) {
+ throw new IllegalArgumentException("Unexpected end of JSON value for
field: " + fieldName);
+ }
+ if (fields == null || fields.contains(fieldName)) {
+ destination.putValue(fieldName, readValue(parser, valueToken));
+ } else if (valueToken.isStructStart()) {
+ parser.skipChildren();
+ }
+ }
+ }
+ return true;
+ }
+
+ /// Materializes the value at the parser's current token in
[JSONRecordExtractor]'s converted shape. Scalars
+ /// are read straight off the parser rather than through databind, which
allocates a fresh
+ /// `DeserializationContext` per `readValue` call. `getNumberValue()` keeps
the binary formats' `Float`
+ /// (never upcast to `Double`) and text JSON's `Double`, matching Jackson's
untyped materialization.
+ @Nullable
+ private Object readValue(JsonParser parser, JsonToken valueToken)
+ throws IOException {
+ switch (valueToken) {
+ case VALUE_STRING:
+ return parser.getText();
+ case VALUE_NUMBER_INT:
+ case VALUE_NUMBER_FLOAT:
+ // Oversized ints widen to BigDecimal via the shared contract (Pinot
has no BigInteger type)
+ return JSONRecordExtractor.convert(parser.getNumberValue());
+ case VALUE_TRUE:
+ return Boolean.TRUE;
+ case VALUE_FALSE:
+ return Boolean.FALSE;
+ case VALUE_NULL:
+ return null;
+ case VALUE_EMBEDDED_OBJECT:
+ // Binary formats' native scalars (e.g. byte[]) pass through
unchanged, as in the map path.
+ return parser.getEmbeddedObject();
+ default:
+ // START_OBJECT / START_ARRAY: containers materialize through databind
and convert recursively.
+ Object value = _valueReader.readValue(parser);
+ return value != null ? JSONRecordExtractor.convert(value) : null;
+ }
+ }
}
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadParser.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadParser.java
index 49b6292f05b..64e6b8e34c5 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadParser.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadParser.java
@@ -19,6 +19,9 @@
package org.apache.pinot.plugin.inputformat.json.format;
import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.pinot.spi.data.readers.GenericRow;
/// Parses a stream payload encoded in a particular (text or binary) JSON
representation into a Jackson-style
@@ -51,4 +54,20 @@ public interface JsonPayloadParser {
/// @throws Exception if the region is not valid for this format
Map<String, Object> parse(byte[] payload, int offset, int length)
throws Exception;
+
+ /// Parses the payload directly into `destination`, avoiding a top-level
per-record map when the
+ /// implementation supports it. When this method returns `false`, it must
leave `destination` unchanged so
+ /// the caller can safely fall back to [#parse]. When it returns `true`, it
must overwrite every requested
+ /// field (using `null` for missing fields) and leave unrequested fields
unchanged. When `fields` is `null`,
+ /// it must populate every top-level field present in the payload. If
parsing throws, `destination` may be
+ /// partially modified and the caller must discard or clear it before reuse.
+ ///
+ /// @param fields fields to populate, or `null` to populate every top-level
field
+ /// @return `true` when the payload was decoded into `destination`; `false`
when the caller
+ /// should fall back to [#parse]
+ default boolean parse(byte[] payload, int offset, int length, GenericRow
destination,
+ @Nullable Set<String> fields)
+ throws Exception {
+ return false;
+ }
}
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/PostgresJsonbPayloadParser.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/PostgresJsonbPayloadParser.java
index 2a6a5b4c9af..e7318f021ae 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/PostgresJsonbPayloadParser.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/PostgresJsonbPayloadParser.java
@@ -19,6 +19,9 @@
package org.apache.pinot.plugin.inputformat.json.format;
import java.util.Map;
+import java.util.Set;
+import javax.annotation.Nullable;
+import org.apache.pinot.spi.data.readers.GenericRow;
import org.apache.pinot.spi.utils.JsonUtils;
@@ -32,13 +35,17 @@ import org.apache.pinot.spi.utils.JsonUtils;
/// logical replication via `pgoutput` — routes through that same send
function, so the version-byte + text
/// framing is what a stream actually carries.
///
-/// Because the body is ordinary text JSON, values decode through
[JsonUtils#bytesToMap] and therefore follow
-/// exactly the same type contract as [TextJsonPayloadParser].
-class PostgresJsonbPayloadParser implements JsonPayloadParser {
+/// Because the body is ordinary text JSON, values follow exactly the same
type contract as
+/// [TextJsonPayloadParser].
+class PostgresJsonbPayloadParser extends JacksonPayloadParser {
/// The only version `jsonb_recv` accepts.
private static final byte JSONB_VERSION = 1;
+ PostgresJsonbPayloadParser() {
+ super(JsonUtils.DEFAULT_READER);
+ }
+
@Override
public boolean matches(byte[] payload, int offset, int length) {
// Version byte followed by a text JSON document. Requiring the JSON start
character keeps this from
@@ -50,9 +57,21 @@ class PostgresJsonbPayloadParser implements
JsonPayloadParser {
@Override
public Map<String, Object> parse(byte[] payload, int offset, int length)
throws Exception {
+ validate(payload, offset, length);
+ return parseMap(payload, offset + 1, length - 1);
+ }
+
+ @Override
+ public boolean parse(byte[] payload, int offset, int length, GenericRow
destination,
+ @Nullable Set<String> fields)
+ throws Exception {
+ validate(payload, offset, length);
+ return super.parse(payload, offset + 1, length - 1, destination, fields);
+ }
+
+ private static void validate(byte[] payload, int offset, int length) {
if (length < 2 || payload[offset] != JSONB_VERSION) {
throw new IllegalArgumentException("Payload is not a version-1
PostgreSQL jsonb value");
}
- return JsonUtils.bytesToMap(payload, offset + 1, length - 1);
}
}
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/TextJsonPayloadParser.java
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/TextJsonPayloadParser.java
index 397fea32094..e4bb9b2d986 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/TextJsonPayloadParser.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/TextJsonPayloadParser.java
@@ -18,13 +18,16 @@
*/
package org.apache.pinot.plugin.inputformat.json.format;
-import java.util.Map;
import org.apache.pinot.spi.utils.JsonUtils;
/// Parses UTF-8 text JSON, the historical
[org.apache.pinot.plugin.inputformat.json.JSONMessageDecoder]
-/// behavior. Delegates to [JsonUtils#bytesToMap] so the produced value types
exactly match the rest of Pinot.
-class TextJsonPayloadParser implements JsonPayloadParser {
+/// behavior. Uses the same Jackson map/value materialization contract as the
binary JSON parsers.
+class TextJsonPayloadParser extends JacksonPayloadParser {
+
+ TextJsonPayloadParser() {
+ super(JsonUtils.DEFAULT_READER);
+ }
@Override
public boolean matches(byte[] payload, int offset, int length) {
@@ -46,10 +49,4 @@ class TextJsonPayloadParser implements JsonPayloadParser {
}
return false;
}
-
- @Override
- public Map<String, Object> parse(byte[] payload, int offset, int length)
- throws Exception {
- return JsonUtils.bytesToMap(payload, offset, length);
- }
}
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderBinaryTest.java
b/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderBinaryTest.java
index 9e21ada953b..64d622a9d3b 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderBinaryTest.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderBinaryTest.java
@@ -24,6 +24,7 @@ import com.fasterxml.jackson.dataformat.cbor.CBORGenerator;
import com.fasterxml.jackson.dataformat.smile.SmileFactory;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import org.apache.pinot.spi.data.readers.GenericRow;
@@ -99,6 +100,23 @@ public class JSONMessageDecoderBinaryTest {
assertRich(decode(Map.of(), RICH_FIELDS, TEXT_DOC));
}
+ /// The direct streaming path materializes top-level scalars straight off
the parser; guard the binary-only
+ /// scalar shapes (Float must not upcast to Double, byte[] must pass
through) at the decoder level, with and
+ /// without a field selection.
+ @Test
+ public void testDirectDecodePreservesBinaryScalars()
+ throws Exception {
+ Map<String, Object> doc = Map.of("f", 1.5f, "bin", new byte[]{1, 2, 3});
+ for (byte[] payload : List.of(smile(doc), cbor(doc))) {
+ for (Set<String> fields : Arrays.asList(null, Set.of("f", "bin"))) {
+ GenericRow row = decode(AUTO, fields, payload);
+ assertTrue(row.getValue("f") instanceof Float, "expected Float, got "
+ row.getValue("f").getClass());
+ assertEquals(row.getValue("f"), 1.5f);
+ assertEquals((byte[]) row.getValue("bin"), new byte[]{1, 2, 3});
+ }
+ }
+ }
+
/// An unset jsonFormat must not silently auto-detect binary payloads: those
streams keep failing as they did
/// before this feature existed, rather than being decoded (or worse,
ingested as a partial row).
@Test
diff --git
a/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderTest.java
b/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderTest.java
index 5c2bda51b87..2a81b23b533 100644
---
a/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderTest.java
+++
b/pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderTest.java
@@ -22,23 +22,114 @@ import com.fasterxml.jackson.databind.JsonNode;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileReader;
+import java.math.BigDecimal;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;
+import java.util.Set;
import org.apache.pinot.spi.data.FieldSpec;
import org.apache.pinot.spi.data.FieldSpec.DataType;
import org.apache.pinot.spi.data.Schema;
import org.apache.pinot.spi.data.readers.GenericRow;
+import org.apache.pinot.spi.stream.StreamMessageDecoder;
import org.apache.pinot.spi.utils.JsonUtils;
import org.testng.annotations.Test;
import static org.testng.Assert.assertEquals;
import static org.testng.Assert.assertNotNull;
+import static org.testng.Assert.assertThrows;
import static org.testng.Assert.fail;
public class JSONMessageDecoderTest {
+ @Test
+ public void testDirectDecodePreservesValueConversion()
+ throws Exception {
+ byte[] payload = ("{\"id\":1,\"huge\":99999999999999999999999999,"
+ + "\"nested\":{\"values\":[1,null,3]},\"tags\":[\"a\",\"b\"],"
+ +
"\"flag\":true,\"off\":false,\"gone\":null}").getBytes(StandardCharsets.UTF_8);
+ JSONMessageDecoder decoder = new JSONMessageDecoder();
+ decoder.init(Map.of(), null, "testTopic");
+ GenericRow row = new GenericRow();
+ // Without a field selection there is no pre-nulling pass, so an explicit
JSON null must still overwrite
+ // the previous message's value in a reused row.
+ row.putValue("gone", "stale");
+
+ decoder.decode(payload, row);
+
+ assertEquals(row.getValue("id"), 1);
+ assertEquals(row.getValue("huge"), new
BigDecimal("99999999999999999999999999"));
+ assertEquals((Object[]) ((Map<?, ?>)
row.getValue("nested")).get("values"), new Object[]{1, null, 3});
+ assertEquals((Object[]) row.getValue("tags"), new Object[]{"a", "b"});
+ assertEquals(row.getValue("flag"), Boolean.TRUE);
+ assertEquals(row.getValue("off"), Boolean.FALSE);
+ assertEquals(row.getValue("gone"), null);
+ }
+
+ @Test
+ public void testDirectDecodeSelectedFieldsOverwritesMissingValues()
+ throws Exception {
+ JSONMessageDecoder decoder = new JSONMessageDecoder();
+ decoder.init(Map.of(), Set.of("id", "missing"), "testTopic");
+ GenericRow row = new GenericRow();
+ row.putValue("missing", "stale");
+ row.putValue("unselected", "preserved");
+
+
decoder.decode("{\"id\":1,\"ignored\":[1,2,3]}".getBytes(StandardCharsets.UTF_8),
row);
+
+ assertEquals(row.getValue("id"), 1);
+ assertEquals(row.getValue("missing"), null);
+ assertEquals(row.getValue("unselected"), "preserved");
+ assertEquals(row.getFieldToValueMap().keySet(), Set.of("id", "missing",
"unselected"));
+ }
+
+ @Test
+ public void testDirectDecodeHonorsOffsetAndLength()
+ throws Exception {
+ String prefix = "invalid-prefix";
+ String record = "{\"id\":1,\"name\":\"alice\"}";
+ byte[] payload = (prefix + record +
"invalid-suffix").getBytes(StandardCharsets.UTF_8);
+ int offset = prefix.getBytes(StandardCharsets.UTF_8).length;
+ int length = record.getBytes(StandardCharsets.UTF_8).length;
+
+ JSONMessageDecoder allFieldsDecoder = new JSONMessageDecoder();
+ allFieldsDecoder.init(Map.of(), null, "testTopic");
+ GenericRow allFieldsRow = allFieldsDecoder.decode(payload, offset, length,
new GenericRow());
+ assertEquals(allFieldsRow.getFieldToValueMap(), Map.of("id", 1, "name",
"alice"));
+
+ JSONMessageDecoder selectedFieldsDecoder = new JSONMessageDecoder();
+ selectedFieldsDecoder.init(Map.of(), Set.of("id"), "testTopic");
+ GenericRow selectedFieldsRow = selectedFieldsDecoder.decode(payload,
offset, length, new GenericRow());
+ assertEquals(selectedFieldsRow.getFieldToValueMap(), Map.of("id", 1));
+
+ // Exclude the closing brace to prove the parser honors length instead of
reading the remaining array.
+ assertThrows(RuntimeException.class,
+ () -> allFieldsDecoder.decode(payload, offset, length - 1, new
GenericRow()));
+ }
+
+ @Test
+ public void testCustomExtractorUsesMapFallback()
+ throws Exception {
+ JSONMessageDecoder decoder = new JSONMessageDecoder();
+ decoder.init(Map.of(StreamMessageDecoder.RECORD_EXTRACTOR_CONFIG_KEY,
CustomJSONRecordExtractor.class.getName()),
+ Set.of("id"), "testTopic");
+
+ GenericRow row =
decoder.decode("{\"id\":1}".getBytes(StandardCharsets.UTF_8), new GenericRow());
+
+ assertEquals(row.getValue("id"), 1);
+ assertEquals(row.getValue("custom"), true);
+ }
+
+ public static class CustomJSONRecordExtractor extends JSONRecordExtractor {
+ @Override
+ public GenericRow extract(Map<String, Object> from, GenericRow to) {
+ to.putValue("custom", true);
+ return super.extract(from, to);
+ }
+ }
+
@Test
public void testJsonDecoderWithoutOutgoingTimeSpec()
throws Exception {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]