[python] Add efficient VARIANT path get and replace - #9147
Conversation
|
Just DOUBLE? |
Add float support too. |
|
Could we clarify whether this is intended to be a general public VARIANT API or an internal fast path? I could not find an equivalent VARIANT mutation function in Databricks; its documented VARIANT functions are primarily for extraction, inspection, and explosion. JSON APIs, however, have fairly established mutation semantics:
MySQL can optimize Would an API such as References: Databricks VARIANT functions, MySQL JSON modification functions, MySQL partial JSON updates, BigQuery JSON_SET, Oracle JSON_TRANSFORM. |
|
One additional implementation idea: the fast path could be based on encoded-length invariance rather than being hard-coded to FLOAT and DOUBLE. For an existing path, after computing and encoding the replacement, if the encoded value has the same length as the old encoded value, the implementation can patch the complete encoded slice directly. The metadata dictionary and all ancestor offset tables remain valid because neither the path nor any byte positions change. This would naturally cover all fixed-width values and potentially variable-width values whose encoded length is unchanged. If the encoded length differs, or the path is missing and must be created, the implementation can fall back to rebuilding the affected value. There is also an Arrow-level optimization opportunity. The current |
Maybe |
|
Yes, I think
With that contract, I would not add I also agree that current = variant_get(column, "$.velocity.y", pa.float64())
updated = pc.negate(current)
result = variant_set(column, "$.velocity.y", updated)Here The implementation can then choose its strategy independently of the public semantics:
If missing-path creation is out of scope for this PR, I would expose the current operation as |
JingsongLi
left a comment
There was a problem hiding this comment.
Re-reviewed the latest head. The sliced-buffer copy issue is fixed; the remaining reproducible findings are inline below.
| ) | ||
| if new_size is None: | ||
| new_size = len(encoded) | ||
| if new_size != _value_size(value, pos): |
There was a problem hiding this comment.
[P1] Please reject a truncated target before entering the fast patch path. _value_size returns the fixed logical size for a primitive, but it does not prove that those bytes remain inside the current BinaryArray row. I reproduced this with an object whose child contains only a DOUBLE header followed by a valid row: new_size == _value_size(...), so the 9-byte patch crosses the first row boundary and overwrites the next row, which then decodes as NULL. Please validate the resolved child and require pos + checked_size <= len(value) (including container offset/sentinel invariants), and explicitly bound every absolute patch range to the current row. A malformed value should raise instead of corrupting an unrelated row in the result.
| ) | ||
| for path, _, target_type in parsed: | ||
| result_chunks[path].append( | ||
| pa.array(results[path], type=target_type)) |
There was a problem hiding this comment.
[P1] Constructing an Arrow array with target_type does not implement Paimon's established variant_get cast contract. For example, LONG -> STRING and OBJECT/ARRAY -> STRING currently raise ArrowTypeError; the Java VariantGet.cast implementation supports primitive casts, JSON serialization for OBJECT/ARRAY -> STRING, and recursive complex-type casts. This makes the same named public API behave differently across Paimon clients. Please implement explicit cast semantics matching Java (including invalid-cast behavior) and add parity tests, or expose this as exact-type extraction rather than accepting a cast-like target_type.
| return struct.unpack_from('<d', value, pos + 1)[0] | ||
| if value_type == _Type.FLOAT: | ||
| return struct.unpack_from('<f', value, pos + 1)[0] | ||
| return GenericVariant(bytes(value), metadata, pos).to_python() |
There was a problem hiding this comment.
[P1] DECIMAL values decoded through this fallback can silently lose precision. GenericVariant.to_python() currently computes the decimal using the default Python Decimal context (precision 28), so a valid DECIMAL16 value such as 12345678901234567890123456789012345678 is returned as 12345678901234567890123456790000000000 by variant_get(..., pa.decimal128(38, 0)). Please decode the unscaled integer and scale exactly (for example with a Decimal tuple), ideally fixing the shared decoder, and add an exact 38-digit regression test.
| or pa.types.is_large_binary(data_type) | ||
| or pa.types.is_date32(data_type) | ||
| or pa.types.is_timestamp(data_type) | ||
| or pa.types.is_decimal128(data_type) |
There was a problem hiding this comment.
[P2] decimal128 is advertised as a supported replacement type here, but ordinary valid Arrow decimals can still fail in the encoder. Replacing with pa.array([Decimal('100.00')], type=pa.decimal128(10, 2)) normalizes the value to 1E+2 and raises append_decimal requires a non-positive exponent. Please encode from the Arrow precision/scale and exact unscaled integer instead of normalizing the Python Decimal, and cover trailing-zero and negative-scale values.
| ) | ||
|
|
||
|
|
||
| def _decode_scalar(value, metadata: bytes, pos: int): |
There was a problem hiding this comment.
[P2] The non-FLOAT/DOUBLE fallback below still copies the entire serialized VARIANT row via bytes(value) before decoding one selected subtree. Extracting a one-byte string from a row with an unrelated 2 MiB binary field allocates roughly 2 MiB, and this repeats for every requested non-float path. That undermines the path API's bounded-work goal. Please decode primitives directly from the memoryview, or copy only value[pos:pos + checked_value_size] and decode it at offset zero.
JingsongLi
left a comment
There was a problem hiding this comment.
Three follow-up findings on the latest head.
| if pa.types.is_boolean(target_type): | ||
| if isinstance(value, str): | ||
| lowered = value.strip().lower() | ||
| if lowered not in ('true', 'false'): | ||
| raise ValueError | ||
| return lowered == 'true' | ||
| if isinstance(value, (bool, int, decimal.Decimal)): | ||
| return value != 0 | ||
| raise TypeError | ||
| if pa.types.is_signed_integer(target_type): | ||
| if not isinstance(value, (bool, int, float, decimal.Decimal, str)): | ||
| raise TypeError | ||
| return _cast_integer(value, target_type) |
There was a problem hiding this comment.
[P2] Preserve Java's source-specific string cast semantics
This routes strings through _cast_integer, which first calls int() and then wraps modulo the target width. As a result, "2147483648" becomes -2147483648 for int32, whereas Java VariantGet rejects the overflow; conversely, "1.9" is rejected here even though BinaryStringUtils truncates it to 1. The boolean branch also rejects Java-supported tokens such as "1" and "yes". Since this API is intended to match Java casts, please dispatch by both source and target type: use the same bounds-checked string parsers and boolean token set for string inputs, and retain narrowing wraparound only for numeric-to-numeric casts.
| fixed_size = _PRIMITIVE_FIXED_SIZES.get(type_info) | ||
| if fixed_size is not None: | ||
| end = pos + fixed_size |
There was a problem hiding this comment.
[P2] Validate DECIMAL precision and scale before accepting its fixed size
Treating every decimal header as size-only allows malformed values such as DECIMAL4 with scale 10, or unscaled 2147483647 at scale 0, to be returned by Python even though Java GenericVariantUtil.getDecimalWithOriginalScale rejects them. Please decode the scale and signed unscaled integer during validation and enforce the DECIMAL4/8/16 precision and scale limits of 9/18/38 before returning the size.
| if (valid_count != len(values.array) | ||
| or values.array.null_count | ||
| or metadata.null_count | ||
| or not len(values.array)): | ||
| return None |
There was a problem hiding this comment.
[P2] Avoid de-vectorizing the entire chunk because of one NULL
This guard sends every row through the Python slow path when a chunk contains even one parent NULL. On 50,000 otherwise identical rows, one NULL changed variant_get from 0.0019 s to 0.319 s and variant_replace from 0.0022 s to 0.257 s (roughly 118–165× slower). Nullable VARIANT columns are common, so could we use the struct validity bitmap as a row mask, vectorize the valid rows, then scatter NULLs back for get and skip them for replace?
|
+1 |
Purpose
Add Arrow APIs for reading and replacing existing VARIANT paths without decoding unrelated fields.
Semantics
strict=Truerejects them.Implementation
Performance
A local 20K-row benchmark updating four DOUBLE paths completed
get + replaceat about 720K rows/s.Tests
VARIANT regression suite: 133 passed, 15 subtests passed.