Skip to content

Support binary JSON payload formats (PostgreSQL jsonb, SQLite JSONB, Smile, CBOR) in the JSON stream decoder#18953

Open
xiangfu0 wants to merge 10 commits into
apache:masterfrom
xiangfu0:claude/jsonb-pinot-json-decoder-5af7ba
Open

Support binary JSON payload formats (PostgreSQL jsonb, SQLite JSONB, Smile, CBOR) in the JSON stream decoder#18953
xiangfu0 wants to merge 10 commits into
apache:masterfrom
xiangfu0:claude/jsonb-pinot-json-decoder-5af7ba

Conversation

@xiangfu0

@xiangfu0 xiangfu0 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Summary

JSONMessageDecoder only understood UTF-8 text JSON. This adds a pluggable payload-format layer so a stream can also carry PostgreSQL jsonb, SQLite JSONB, Smile, or CBOR, selected via a new jsonFormat decoder property.

Every format decodes to the same Map<String, Object> value contract Jackson produces for text JSON, so the existing JSONRecordExtractor handles all of them unchanged.

Configuration

jsonFormat Payload
(unset) / TEXT UTF-8 text JSON — the decoder's historical behavior
POSTGRES_JSONB PostgreSQL jsonb wire format
SQLITE_JSONB SQLite 3.45+ JSONB
SMILE Jackson Smile
CBOR CBOR (RFC 8949)
AUTO Opt-in: detect per message from leading magic/version bytes, falling back to text
"stream.kafka.decoder.prop.jsonFormat": "SQLITE_JSONB"

Backward compatibility

An unset jsonFormat means TEXT, so existing tables are byte-for-byte unchanged. No existing config keys, SPI signatures, or serialization formats change.

AUTO is deliberately opt-in rather than the default. It never mis-routes a well-formed text JSON document — a top-level { / [ (optionally after whitespace) collides with none of the binary signatures — but it is still a heuristic over a few leading bytes, so it can claim a corrupt message that text decoding would have rejected outright. Enabling that for every existing stream would be a silent correctness change. Detection order is Smile → CBOR → Postgres → SQLite → text.

To keep AUTO itself safe, SQLite detection requires more than the OBJECT low nibble (which one arbitrary binary byte in sixteen would satisfy): the top-level object's declared size must exactly fill the payload, the same validity rule the parser enforces.

Notes on the two hand-rolled decoders

PostgreSQL jsonb's wire format is not its on-disk JsonbContainer layout. jsonb_send renders the value with JsonbToCString and emits a version byte followed by text JSON; jsonb_recv reverses it. Every standard binary producer — the v3 extended-query protocol, COPY ... WITH (FORMAT binary), and logical replication via pgoutput — routes through that same send function. So this parser strips the version byte and parses the text body, which also makes its type contract identical to TEXT.

SQLite JSONB is a genuine binary format, decoded per spec including the JSON5 element types (INT5, FLOAT5, TEXTJ, TEXT5, TEXTRAW) and all four size descriptors. SQLite's validity rule that the top-level element exactly fill the BLOB is enforced, so a short-declared element cannot yield a partial (or empty) row. Nested elements are bounded by their enclosing container rather than merely by the payload, so a crafted element cannot overrun its parent and swallow later siblings. TEXTJ rejects invalid escapes rather than passing them through; string unescaping is single-pass with a no-allocation fast path.

Documentation

A user manual for the plugin ships in the PR at pinot-plugins/pinot-input-format/pinot-json/README.md: the jsonFormat values, the TEXT default and opt-in AUTO (with the per-message detection order and signatures), real-time table config examples for text / a pinned binary format / AUTO, per-format notes, and the decode-failure diagnostic.

Dependencies

Adds jackson-dataformat-smile and jackson-dataformat-cbor. Versions are managed by the existing jackson-bom import (no version overrides). Both are recorded in LICENSE-binary.

Testing

68 tests pass in the module. Coverage includes:

  • Per-format decoding with type-strict assertions (Integer / Long / BigInteger narrowing tiers, Double, and Float / byte[] passthrough for the binary formats).
  • Cross-format equivalence — one logical document decoded through all five formats yields an equal Map.
  • Default safety — an unset jsonFormat decodes text and rejects Smile, CBOR, Postgres, SQLite, and a bare 0x0c, rather than silently detecting them.
  • AUTO detection when explicitly enabled, including the fallback path, adversarial input (empty payload, 0xFF, truncated Smile header, lone 0x01), that tag-less CBOR is not auto-detected but still decodes when pinned, and that SQLite detection accepts only exactly-filling objects.
  • Negative / malformed input — truncated payloads, reserved element types, non-object top level, trailing bytes, unsupported version byte, nested-element container overrun, invalid TEXTJ escape, and end-to-end decode() wrapping parse failures in a bounded, charset-safe RuntimeException.
  • End-to-end decode()GenericRow for every format, both pinned and auto-detected.

Release notes

Adds the jsonFormat decoder property to JSONMessageDecoder, supporting TEXT (default), POSTGRES_JSONB, SQLITE_JSONB, SMILE, CBOR, and an opt-in AUTO that detects the encoding per message. Existing tables are unaffected: an unset property behaves exactly as before.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR extends the pinot-json stream decoder to support multiple binary JSON payload encodings (PostgreSQL jsonb wire framing, SQLite JSONB, Smile, CBOR) in addition to UTF-8 text JSON by introducing a payload-format abstraction with optional per-message auto-detection.

Changes:

  • Introduces JsonPayloadParser + JsonPayloadFormat (AUTO default) and wires JSONMessageDecoder to parse via the selected parser.
  • Adds concrete payload parsers for TEXT, PostgreSQL jsonb (version byte + text body), SQLite JSONB (hand-rolled), Smile, and CBOR (Jackson-backed).
  • Adds unit/integration-style tests for per-format decoding and AUTO detection, plus module dependencies for Jackson Smile/CBOR and updates LICENSE-binary.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoder.java Adds jsonFormat config, parser selection, and uses parser in decode()
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadParser.java Defines the parser contract (matches + parse) for text/binary payloads
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadFormat.java Adds enum of formats, config parsing, and AUTO detection order
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/AutoDetectPayloadParser.java Implements AUTO parser delegating to detected concrete parser
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/TextJsonPayloadParser.java Text JSON parser delegating to JsonUtils.bytesToMap
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/PostgresJsonbPayloadParser.java Parses Postgres jsonb wire framing (version byte + text JSON)
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/SqliteJsonbPayloadParser.java Implements SQLite JSONB decoder with container-bound validation
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/JacksonPayloadParser.java Shared base for Jackson-backed binary formats using ObjectReader
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/SmileJsonPayloadParser.java Smile parser + header-based AUTO detection
pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/format/CborJsonPayloadParser.java CBOR parser + self-describe-tag-based AUTO detection
pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoderBinaryTest.java End-to-end decode → GenericRow tests for configured and AUTO formats
pinot-plugins/pinot-input-format/pinot-json/src/test/java/org/apache/pinot/plugin/inputformat/json/format/JsonPayloadFormatTest.java Unit tests for parsers, detection, equivalence, and error cases
pinot-plugins/pinot-input-format/pinot-json/pom.xml Adds Jackson Smile + CBOR dependencies
LICENSE-binary Records new binary dependencies
Comments suppressed due to low confidence (1)

pinot-plugins/pinot-input-format/pinot-json/src/main/java/org/apache/pinot/plugin/inputformat/json/JSONMessageDecoder.java:83

  • The exception wrapper always builds new String(payload, offset, length) using the platform default charset. With the new binary payload formats (Smile/CBOR/SQLite JSONB), this can allocate a very large string of unreadable data, potentially throw due to invalid byte sequences, and can leak raw binary into logs. Consider logging only metadata (length/offset) plus a small hex prefix instead.
    } catch (Exception e) {
      throw new RuntimeException(
          "Caught exception while decoding JSON record with payload: " + new String(payload, offset, length), e);
    }

@codecov-commenter

codecov-commenter commented Jul 10, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.21429% with 47 lines in your changes missing coverage. Please review.
✅ Project coverage is 65.09%. Comparing base (a2a27a1) to head (ed8144a).

Files with missing lines Patch % Lines
...utformat/json/format/SqliteJsonbPayloadParser.java 79.90% 25 Missing and 17 partials ⚠️
...inputformat/json/format/TextJsonPayloadParser.java 77.77% 1 Missing and 1 partial ⚠️
...ot/plugin/inputformat/json/JSONMessageDecoder.java 95.65% 0 Missing and 1 partial ⚠️
...inputformat/json/format/CborJsonPayloadParser.java 66.66% 0 Missing and 1 partial ⚠️
...nputformat/json/format/SmileJsonPayloadParser.java 66.66% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master   #18953      +/-   ##
============================================
+ Coverage     65.05%   65.09%   +0.03%     
  Complexity     1403     1403              
============================================
  Files          3399     3407       +8     
  Lines        212808   213087     +279     
  Branches      33568    33606      +38     
============================================
+ Hits         138443   138701     +258     
+ Misses        63223    63217       -6     
- Partials      11142    11169      +27     
Flag Coverage Δ
custom-integration1 100.00% <ø> (ø)
integration 100.00% <ø> (ø)
integration1 100.00% <ø> (ø)
integration2 0.00% <ø> (ø)
java-21 65.09% <83.21%> (+0.03%) ⬆️
temurin 65.09% <83.21%> (+0.03%) ⬆️
unittests 65.08% <83.21%> (+0.03%) ⬆️
unittests1 56.88% <0.00%> (-0.11%) ⬇️
unittests2 37.45% <83.21%> (+0.07%) ⬆️

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

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

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

@xiangfu0

Copy link
Copy Markdown
Contributor Author

All review comments are addressed. Summary of the two follow-up commits:

4b7401f — review fixes

  • @xiangfu0's SQLite exact-fill bug (the substantive one). Reproduced: 0x0C (OBJECT, payload size 0) followed by real data decoded to {} and silently dropped the trailing bytes. matches() claims any payload whose first byte carries the OBJECT nibble, so AUTO made this reachable for arbitrary input — a corrupt stream message became an empty ingested row. parse() now enforces SQLite's exact-fill rule, with regression tests at both the parser and JSONMessageDecoder levels (and confirming a lone 0x0C empty object still parses).
  • Malformed {@code {} Javadoc construct (verified with mvn javadoc:javadoc).
  • fromConfig() now preserves the valueOf failure as the exception cause.
  • testDefaultIsTexttestUnsetFormatAutoDetectsText (the default is AUTO, not pinned TEXT).
  • Test fixtures pinned to StandardCharsets.UTF_8.
  • Coverage for previously untested SQLite branches: size descriptors 14/15 including oversized-uint64 rejection, signed hex INT5, FLOAT5 with a leading +, the \u escape, the TEXT5 escape extensions, and the missing-value / non-text-label errors.

5289a94 — bounded, charset-safe decode diagnostic

Also taking Copilot's low-confidence note on the exception message, which is a real problem now that payloads can be binary: new String(payload, offset, length) used the platform default charset and echoed the entire payload, so a large Smile/CBOR/JSONB message was dumped into the log as unbounded mojibake. It now renders at most the first 128 bytes — as UTF-8 when they contain no control characters other than whitespace, as hex otherwise — and always reports the true length. Text payloads keep their readable rendering; binary payloads become a short hex prefix.

Module is at 65 tests, all passing; spotless / checkstyle / license clean.

@xiangfu0

Copy link
Copy Markdown
Contributor Author

Two more commits, then CI green on ed03fea (13/13).

  • 32aa635 — split the decode-failure diagnostic into a 512-byte text window and a 128-byte hex window. My first cut used one 128-byte cap, which kept binary payloads out of the log but truncated malformed text records before the offending field, losing the operator context the old full-payload message gave. Hex costs two characters per byte, so the two caps produce messages of comparable length.

  • ed03fea — assert two behaviors that were claimed above but not actually tested. The assertThrows on fromConfig passed even before the cause was chained, and every describePayload fixture was pure ASCII or contained a control byte forcing the hex path, so the "bytes >= 0x80 still render as text" branch was never exercised. Both now have real assertions.

Final state: 66 tests in the module, all green; spotless / checkstyle / license clean; all 13 CI checks passing.

@xiangfu0 xiangfu0 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Found one high-signal issue; see inline comment.

@Jackie-Jiang Jackie-Jiang added ingestion Related to data ingestion pipeline feature New functionality real-time Related to realtime table ingestion and serving labels Jul 10, 2026
@xiangfu0 xiangfu0 force-pushed the claude/jsonb-pinot-json-decoder-5af7ba branch from 56555c4 to 976ec7d Compare July 10, 2026 08:08

@xiangfu0 xiangfu0 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Found two high-signal SQLite JSONB fail-fast correctness issues; see inline comments.

@xiangfu0

Copy link
Copy Markdown
Contributor Author

Added a user manual with examples in 23ea2d4: pinot-plugins/pinot-input-format/pinot-json/README.md.

It documents the jsonFormat decoder property — the supported values, the TEXT default and opt-in AUTO detection (with the per-message detection order and each format's signature), real-time table streamConfigMaps examples for text, a pinned binary format, and AUTO, plus per-format notes (Postgres wire framing, SQLite JSON5 + exact-fill, Smile/CBOR magic bytes) and the bounded decode-failure diagnostic.

(The branch was also rebased onto latest master; CI is green on the pre-README head 976ec7d, and this doc-only commit fast-forwards on top.)

@xiangfu0 xiangfu0 force-pushed the claude/jsonb-pinot-json-decoder-5af7ba branch from f270bee to b6e2008 Compare July 11, 2026 08:02
xiangfu0 added 9 commits July 13, 2026 01:01
JSONMessageDecoder only understood UTF-8 text JSON. Add a pluggable
payload-format layer so a stream can also carry PostgreSQL jsonb, SQLite
JSONB, Smile, or CBOR, selected with the new `jsonFormat` decoder
property.

When `jsonFormat` is unset (equivalently AUTO) the encoding is detected
per message from its leading magic/version bytes, falling back to text
JSON. Detection is allocation-free and cannot mis-route a well-formed
text JSON document: a top-level `{`/`[`, optionally after whitespace,
collides with none of the binary signatures. Pin `TEXT` to skip
detection entirely.

Every format decodes to the same `Map<String, Object>` contract Jackson
produces for text JSON, so the existing JSONRecordExtractor handles all
of them unchanged.

Notes on the two hand-rolled decoders:

- PostgreSQL jsonb's wire format is *not* its on-disk JsonbContainer
  layout. `jsonb_send` renders the value with JsonbToCString and emits a
  version byte followed by text JSON; `jsonb_recv` reverses it. Every
  standard binary producer (v3 protocol, COPY ... WITH (FORMAT binary),
  logical replication via pgoutput) routes through that send function,
  so this parser strips the version byte and parses the text body.

- SQLite JSONB (3.45+) is a genuine binary format and is decoded per
  spec, including the JSON5 element types. Nested elements are bounded
  by their enclosing container, not merely by the payload, so a crafted
  element cannot overrun its parent and silently swallow later siblings.

Adds jackson-dataformat-smile and jackson-dataformat-cbor (versions
managed by jackson-bom) and records both in LICENSE-binary.
…eir top-level element

SQLite's JSONB validity rule requires the outer element to exactly fill
the BLOB. Without that check a top-level element declaring a short size
(e.g. a bare 0x0C empty object followed by real data) decoded to a
partial -- possibly empty -- row and silently discarded the trailing
bytes, which AUTO made reachable for any payload whose first byte
carries the OBJECT nibble. Reject when the cursor does not land on the
payload limit, and cover it with parser- and decoder-level regression
tests.

Also from review:
- Fix a malformed nested {@code {} construct in the JSONMessageDecoder
  Javadoc that terminated the inline tag early.
- Keep the original exception as the cause when fromConfig() rejects an
  unsupported jsonFormat.
- Rename testDefaultIsText -> testUnsetFormatAutoDetectsText; the
  default is AUTO, not a pinned TEXT format.
- Pin test fixtures to UTF-8 instead of the platform default charset.

Adds coverage for the previously untested SQLite branches: size
descriptors 14/15 (including rejection of an oversized uint64 size),
signed hex INT5, FLOAT5 with a leading '+', the \u escape, the TEXT5
escape extensions, and the missing-value / non-text-label errors.
The decode() failure message echoed the whole payload via
new String(payload, offset, length), which uses the platform default
charset. That was tolerable when the payload was always text JSON, but
the binary encodings make it a liability: an arbitrarily large Smile /
CBOR / SQLite JSONB message was decoded as mojibake and dumped into the
log in full.

Render at most the first 128 bytes, as UTF-8 when they contain no
control characters other than whitespace and as hex otherwise, and
always report the true payload length. Text payloads keep their readable
rendering; binary payloads become a short hex prefix.
A single 128-byte cap kept binary payloads out of the log but truncated
malformed text records before the offending field, losing the operator
context the old full-payload message provided.

Render up to 512 bytes of text but only 128 bytes as hex; since hex costs
two characters per byte, both produce a message of comparable length.
…sted

Both changes were described in the review thread but nothing verified
them, so a regression would have gone unnoticed:

- fromConfig() now has a test that captures the IllegalArgumentException
  and asserts both the message contents and that the underlying valueOf
  failure survives as the cause. The previous assertThrows passed even
  before the cause was chained.

- describePayload()'s "bytes >= 0x80 are not control characters, so
  non-ASCII UTF-8 still renders as text" branch had no fixture: every
  case was pure ASCII or contained a low control byte forcing the hex
  path. Added a multibyte UTF-8 payload.
…act fill

Defaulting an unset jsonFormat to AUTO turned per-message detection on
for every existing JSON stream, which is a backward-compatibility and
data-correctness change: SqliteJsonbPayloadParser.matches() gated only on
the OBJECT low nibble, so roughly one arbitrary binary byte in sixteen
claimed the payload, and a lone 0x0c is a validly-exact empty object. A
corrupt message that previously failed text decoding could therefore be
ingested as an empty row.

Two changes:

- An unset jsonFormat now resolves to TEXT, preserving the decoder's
  historical behavior byte for byte. AUTO must be requested explicitly.

- SQLite detection additionally requires the top-level object's declared
  size to exactly fill the payload -- the same validity rule parse()
  enforces -- so AUTO can no longer hand a corrupt message to that parser
  on the strength of a single nibble. The check is allocation-free and
  covers all four size descriptors, including an 8-byte size whose sign
  bit is set.

Tests assert that an unset format decodes text and rejects Smile, CBOR,
Postgres, SQLite and a bare 0x0c; that explicit AUTO still detects each
format; and that detection accepts only exactly-filling SQLite objects.
matches() and readElement() each carried their own copy of the element
header layout (size-descriptor -> header width and declared size). They
agreed, but nothing held them to it: every existing size-descriptor test
drives parse(), so a byte-order slip in the matches() copy -- reading a
2-byte size as 0x0400 rather than 0x0004, say -- would have shipped
silently, either rejecting a valid SQLite object so AUTO fell back to
text, or claiming a payload that does not fill.

Extract headerLength() and declaredSize() and call them from both, so
detection and parsing can no longer disagree about the layout, and the
parse() tests now exercise the same code detection uses.

Assert matches() over every size descriptor rather than only 14: an
exactly-filling and an off-by-one case for each of 12/13/14/15, a
byte-order guard for the 2-byte size, truncated headers for each width,
and that anything matches() claims parse() also accepts.
A user manual for the JSON input format plugin: the jsonFormat values,
the TEXT default and opt-in AUTO detection (with the per-message
detection order and its signatures), realtime table config examples for
text, a pinned binary format, and AUTO, plus per-format notes and the
decode-failure diagnostic behavior.
The SQLite JSONB decoder was more permissive than text JSON on two paths,
letting a corrupt stream message ingest a value Jackson would reject:

- Canonical numbers (TYPE_INT / TYPE_FLOAT) shared the permissive JSON5
  parsers. Double.parseDouble accepts NaN, Infinity, a leading '+', Java
  hex floats and type suffixes; Long.parseLong / BigInteger accept a
  leading '+' and leading zeros. So a malformed canonical number decoded
  to a non-finite or non-canonical value instead of failing the record.
  Validate TYPE_INT / TYPE_FLOAT against the RFC 8259 number grammar
  before parsing, keeping the permissive handling only for the JSON5
  types (TYPE_INT5 / TYPE_FLOAT5).

- Text was decoded with new String(..., UTF_8), which substitutes U+FFFD
  for malformed bytes. A corrupt record could therefore ingest mutated
  field names or values. Decode with a CharsetDecoder configured for
  CodingErrorAction.REPORT so invalid UTF-8 fails the record.

Adds regressions: canonical NaN / Infinity / +1.5 / hex-float and +5 /
007 are rejected while the JSON5 variants and canonical happy paths still
decode; valid multibyte UTF-8 decodes while a lone invalid byte in a
value or field name is rejected.
@xiangfu0 xiangfu0 force-pushed the claude/jsonb-pinot-json-decoder-5af7ba branch from b6e2008 to 9d164f7 Compare July 13, 2026 08:02
@gortiz

gortiz commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Reviewed this focusing on the two hand-rolled binary parsers (which parse untrusted stream bytes on the ingestion hot path). Overall this is careful work — bounds arithmetic is defensive (compare-without-add to avoid overflow, sign-bit uint64 sizes rejected), UTF-8 uses CodingErrorAction.REPORT so corrupt bytes fail rather than silently substitute, canonical numbers are regex-validated before the permissive parse*, exact-fill + container-bounded nesting are enforced, and backward compatibility (unset → TEXT, AUTO opt-in) is preserved and tested. Nice test coverage too.

One blocker, plus a few smaller notes.

🔴 Blocker: unbounded recursion in SqliteJsonbPayloadParserStackOverflowError takes the whole consuming segment to ERROR

readElementreadArray/readObjectreadElement recurses one frame per nesting level with no depth cap (SqliteJsonbPayloadParser.java:162,206-208,216-237). Each nesting level costs only ~1–3 bytes on the wire (a TYPE_ARRAY/TYPE_OBJECT header plus its size field), so an ordinary stream message of a few KB — well under Kafka's default 1 MB max.message.bytes — produces thousands of stack frames and throws StackOverflowError.

The problem is not the throw itself, it's that StackOverflowError is an Error, not an Exception, so it escapes every decode-error guard on the path and is not handled as a bad record:

  • JSONMessageDecoder.decode catches only catch (Exception e) (JSONMessageDecoder.java:89) — so it's not wrapped in the bounded RuntimeException this PR documents.
  • StreamDataDecoderImpl.decode also catches only catch (Exception e) (StreamDataDecoderImpl.java:96) — so the normal "drop the row / stop-on-decode-error" handling in RealtimeSegmentDataManager.processStreamEvents never runs.
  • The Error propagates out of consumeLoop into the consumer thread's catch (Throwable e) in RealtimeSegmentDataManager.run() (line ~1017), which sets _state = State.ERROR, drops LLC_PARTITION_CONSUMING to 0, and returns.

Net effect: a single malformed SQLITE_JSONB message moves the entire consuming segment into ERROR state and stops the partition, instead of dropping one row. Because the offset hasn't advanced past the message, it behaves as a poison pill requiring manual intervention. Every other malformed input in this parser correctly throws IllegalArgumentException (handled gracefully) — this recursion path is the one that escalates to segment failure, which contradicts the error-handling contract the rest of the parser upholds.

Reachable via pinned SQLITE_JSONB (a deeply-nested but otherwise valid, exactly-filling top-level object) and via AUTO (same payload passes matches()'s exact-fill check). The Jackson-backed Smile/CBOR paths are not affected — Jackson's StreamReadConstraints caps nesting depth (~1000) by default. Default TEXT is unaffected.

Suggested fix: cap recursion depth in readElement (mirroring Jackson's ~1000 default) and throw IllegalArgumentException past the limit, so it flows through the existing decode-error handling. A deep-nesting regression test should accompany it — the current tests only exercise shallow nesting (testSqliteNested, testSqliteNestedElementCannotOverrunItsContainer).

🟡 Smaller notes (non-blocking)

  • No cap on numeric-token length. parseInt falls back to new BigInteger(text) for tokens that overflow long (SqliteJsonbPayloadParser.java:250-257), and BigInteger(String) is ~O(n²). A single huge numeric token (bounded only by the payload size, so up to ~1 MB of digits) is a per-message CPU cost with no analogue in Jackson, which caps number length at ~1000 chars via StreamReadConstraints. Worth a length bound for parity.
  • A bare 0x0C decodes to an empty row under SQLITE_JSONB/AUTO (matches() treats it as an exactly-filling empty OBJECT; parse() returns an empty map). Internally consistent and acknowledged by the "AUTO is a heuristic" framing, but under AUTO a stray 0x0C byte becomes an empty record rather than a rejected one — worth a line in the README's AUTO caveats.
  • Trailing bytes after a valid Smile/CBOR object are ignored (no FAIL_ON_TRAILING_TOKENS). This matches the existing text-JSON behavior via JsonUtils.bytesToMap, so it's not a regression — noting only for completeness.

The Postgres parser (version-byte strip + text-JSON body, with matches() requiring both the version byte and a JSON start char) looks correct to me.

Two hardening fixes on the SQLite JSONB decode path (per review):

- Blocker: readElement -> readArray/readObject recursed one frame per
  nesting level with no depth cap. Each level is only ~1-3 wire bytes, so
  a small (sub-MB) but deeply nested message could throw
  StackOverflowError. Because that is an Error, not an Exception, it
  escaped both JSONMessageDecoder.decode and StreamDataDecoderImpl.decode
  (which catch Exception) and propagated to the consumer thread's
  catch (Throwable), moving the whole consuming segment to ERROR -- a
  poison pill from one bad record. Cap nesting at 1000 (Jackson's
  StreamReadConstraints default) and throw IllegalArgumentException past
  it, so deep nesting is handled as a bad record like every other
  malformed input.

- Numeric tokens fell back to new BigInteger(text), which is ~O(n^2). An
  unbounded digit run (up to the payload size) was a per-message CPU sink
  with no analogue in text JSON, which caps number length via Jackson.
  Cap numeric tokens at 1000 characters to match.

Adds deep-nesting and oversized-numeric regression tests (the existing
tests only exercised shallow nesting), and documents the bare-0x0C
empty-object AUTO caveat in the README.
@xiangfu0

Copy link
Copy Markdown
Contributor Author

@gortiz thanks for the careful read — the recursion blocker was a real hole. Addressed in ed8144ac.

🔴 Blocker — unbounded recursion / StackOverflowError → segment ERROR. Fixed. readElement/readArray/readObject now thread a depth counter and reject nesting past 1000 (Jackson StreamReadConstraints' default) with IllegalArgumentException, so deep nesting flows through the normal decode-error handling and drops the one bad record instead of escalating to a StackOverflowError that escapes catch (Exception) and takes the consuming segment to ERROR. Exactly your diagnosis — the Error-vs-Exception distinction is what made it a poison pill. Added testSqliteRejectsExcessiveNestingDepth (builds a 1200-deep exactly-filling payload, asserts IllegalArgumentException, not SOE; a 50-deep one still decodes), since the prior tests only exercised shallow nesting.

🟡 Numeric-token length. Fixed for parity. BigInteger(String) is ~O(n²) and was bounded only by payload size; numeric tokens are now capped at 1000 chars (matching Jackson's number-length limit) before the parse runs. Added testSqliteRejectsOversizedNumericToken (1000 digits OK → BigInteger, 1001 rejected).

🟡 Bare 0x0C → empty row under AUTO. Documented. Added a line to the README's AUTO caveats noting a stray 0x0C is a valid exactly-filling empty object and decodes to an empty row under AUTO — pin TEXT/your format to avoid it.

🟡 Trailing bytes after Smile/CBOR. No change, as you noted — it matches the existing text-JSON behavior via JsonUtils.bytesToMap (no FAIL_ON_TRAILING_TOKENS), so tightening only Smile/CBOR would make the binary paths stricter than text and inconsistent. Happy to add FAIL_ON_TRAILING_TOKENS across all Jackson-backed formats (text included) as a follow-up if you'd prefer that direction.

Module is at 72 tests; spotless/checkstyle/license clean. (Two CI compat jobs are red on an unrelated Apache Kafka-tarball mirror download failure in test setup, not this change — auto-retries have been re-hitting the mirror outage.)

🤖 Addressed by Claude Code

@gortiz gortiz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Verified the fix in ed8144ac against the recursion blocker I raised.

Depth capreadElement/readArray/readObject now thread a depth counter and nextDepth() throws IllegalArgumentException once nesting would exceed MAX_NESTING_DEPTH = 1000 (Jackson's StreamReadConstraints default). Depth increments exactly once per container boundary before descending, and label-position recursion is bounded by the same cap, so deep nesting now flows through the normal decode-error handling instead of escalating to a StackOverflowError that fails the whole consuming segment. testSqliteRejectsExcessiveNestingDepth genuinely distinguishes fixed from broken — a 1200-deep exactly-filling payload asserts IllegalArgumentException (a StackOverflowError would not match), and a 50-deep one still decodes.

Numeric-token length — capped at 1000 chars in Cursor.ascii() (the numeric-only accessor) before the ~O(n²) BigInteger parse; tested at and past the limit.

Bare 0x0C under AUTO — documented in the README caveats, the right disposition for an inherent heuristic property.

All three items from my review are resolved and the blocker is fixed at the root. LGTM.

On trailing bytes: agree with leaving Smile/CBOR consistent with text JSON rather than tightening only the binary paths. FAIL_ON_TRAILING_TOKENS applied uniformly across all Jackson-backed formats would be a reasonable separate follow-up, but not required here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New functionality ingestion Related to data ingestion pipeline real-time Related to realtime table ingestion and serving

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants