Parse a JSON column once per record for repeated jsonPath* transforms#18722
Parse a JSON column once per record for repeated jsonPath* transforms#18722xiangfu0 wants to merge 1 commit into
Conversation
A common ingestion pattern extracts many fields from one JSON-string column - e.g. several JSONPATHSTRING(message, '$.x') transforms on one record - which re-parses the same document once per extracted field. On a JSON-heavy realtime log table this re-parsing dominated transform CPU. Recognize the JsonFunctions.jsonPath* family in the InbuiltFunctionEvaluator and parse a JSON-string first argument once per record, stashing the parsed document in a transient per-record scratch on GenericRow so the other jsonPath* extractions on the same column reuse it. Non-JSON / scalar input is returned unchanged (parseDocOrSelf), so behavior is preserved. The cache lives on the record: it is per-record scoped, freed with the row, never serialized or indexed, and carries no cross-record/thread state - so it scales for high-throughput, many-partition ingestion (unlike a static thread-local cache). It is the automatic equivalent of the explicit jsonExtractObject "parse-once intermediate column" pattern, requiring no config change. Measured (JMH transform pipeline, realistic log record where `message` is a JSON string read 8x): the unmodified config goes from ~69K to ~141K rows/s (~2x), matching an explicitly parse-once-restructured config. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5047433 to
1e23114
Compare
|
Closing — this isn't the right tradeoff. The explicit An automatic per-record parse cache adds a per-call check plus a per-record scratch allocation to the hot ingestion path, and new SPI surface on
Users who extract many fields from one JSON column should use |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #18722 +/- ##
============================================
+ Coverage 56.98% 64.57% +7.58%
- Complexity 7 1291 +1284
============================================
Files 2587 3372 +785
Lines 150212 208722 +58510
Branches 24292 32621 +8329
============================================
+ Hits 85602 134783 +49181
- Misses 57341 63103 +5762
- Partials 7269 10836 +3567
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Motivation
A common ingestion pattern extracts many fields from a single JSON-string column — e.g. several
JSONPATHSTRING(message, '$.x')transforms on one record. EachjsonPath*call re-parses the whole document, so extracting N fields parses it N times. On a JSON-heavy realtime log table this re-parsing dominated transform CPU.#18711 added an explicit, opt-in way to avoid this (
jsonExtractObject(col)materializes a parsed intermediate column). This PR makes it automatic, with the parsed document scoped to the record.Change
InbuiltFunctionEvaluatorrecognizes theJsonFunctions.jsonPath*family (whose first argument is a JSON document) and, on the ingestionexecute(GenericRow)path, parses aStringfirst argument once per record, stashing the parsed document in a new transient per-record scratch onGenericRow. The otherjsonPath*extractions on the same column reuse it.GenericRowgains a transient, identity-keyed scratch (getScratchValue/putScratchValue, reset inclear()). It is not record data: not serialized, not indexed, not copied byinit(), lazily created, and freed with the record.JsonFunctions.parseDocOrSelfparses a JSON object/array string to aMap/List, or returns the input unchanged for anything else (non-container/invalid string, scalar, already-parsed value, null) — so a downstreamjsonPath*call behaves exactly as if it received the raw value.Why on the record, not a static cache
The cache lives on the
GenericRow: per-record scoped, freed with the row, carrying no cross-record or cross-thread state. That scales for high-throughput, many-partition ingestion — unlike a static/thread-local cache that would retain stale parsed documents per thread. It's the automatic equivalent of the explicitjsonExtractObjectpattern; like that pattern, a path landing on a container returns a live node of the shared parsed model, sojsonPath/jsonPathArrayresults are read-only (thejsonPathString/Long/Doubleoverloads copy out a scalar/string and are unaffected).Performance
JMH transform-pipeline benchmark on a realistic log record (
messageis a JSON string read 8×): the unmodified config goes from ~69K to ~141K rows/s (~2×), matching an explicitly parse-once-restructured config (which then adds nothing).Testing
InbuiltFunctionEvaluatorTest#testJsonPathParsesDocumentOncePerRecord: the document is parsed once and stashed on the record (asserts the scratch holds the parsedMap, reused across extractions), non-JSON columns pass through unchanged.JsonFunctionsTest#testParseDocOrSelf: container→Map/List; non-container/scalar/invalid/null/already-parsed→unchanged.InbuiltFunctionEvaluatorTest,JsonFunctionsTest).Backward compatibility
None required. Results are identical to the un-cached path; only the number of parses changes. New
GenericRowmethods are additive (no signature changes); thetransientscratch is excluded from serialization andequals/hashCode. No config / wire-format surface touched.cc SPI maintainers — this touches
GenericRowinpinot-spi(additive only).