Zoltán Borók-Nagy created IMPALA-15460:
------------------------------------------

             Summary: VARIANT: objects whose values are not stored in 
field-name order (valid per spec; Spark writes them) lose fields: variant_get 
returns NULL silently, SELECT of the column fails
                 Key: IMPALA-15460
                 URL: https://issues.apache.org/jira/browse/IMPALA-15460
             Project: IMPALA
          Issue Type: Bug
          Components: Backend
            Reporter: Zoltán Borók-Nagy
            Assignee: Zoltán Borók-Nagy


h3. Problem
The Parquet Variant spec sorts an object's field ids and field offsets by field 
name, but the values can be stored in any order 
([VariantEncoding.md|https://github.com/apache/parquet-format/blob/master/VariantEncoding.md],
 "Value Data for Object"): "The field ids and field offsets must be in 
lexicographical order of the corresponding field names in the metadata 
dictionary. However, the actual {{value}} entries do not need to be in any 
particular order. This implies that the {{field_offset}} values may not be 
monotonically increasing." Spark's {{parse_json}} stores the values in document 
order, and {{to_variant_object}} stores them in struct field or map entry order.

Impala uses the next offset in name order, {{offset\[i+1\]}}, as the end of 
field i. If that offset is smaller than {{offset\[i\]}}, Impala treats the 
field as unreadable:
* {{variant_get}} (typed and untyped) and {{try_variant_get}} return NULL for a 
path that ends at or passes through that field. There is no error and no 
warning, even from the strict {{variant_get}} 
(ff43e6e2cf43e8c9:99b5247900000000). Filters silently drop rows: {{WHERE 
variant_get(v, '$.a.x', 'int') = 2}} returns 0 instead of 1 
(75419747410b1b42:b297002100000000).
* {{variant_to_json}} returns NULL for the whole value, with {{UDF WARNING: 
Failed to read object field}}.
* Returning such a VARIANT to the client fails the whole query with {{Failed to 
read object field}}: {{SELECT * FROM t}} (4c40575dfff512c3:b40b24a300000000), 
{{SELECT id, v ... WHERE id = 2}} (c74366f497c4edda:cbb0855700000000), {{SELECT 
variant_get(v, '$.a') ... WHERE id = 4}} (c647f976c34c257d:0d88e9bb00000000). 
See {{be/src/service/hs2-util.cc:441-447}} and {{query-result-set.cc:254}}.
* {{variant_get(v, path, 'string')}} on an object that contains such a field 
({{'$'}} on row 2) fails with {{UDF ERROR: variant_get: corrupt or unreadable 
variant}} (7645a6436c5f4db7:21d2b20600000000). {{try_variant_get}} returns NULL 
with that warning (0b4db80a99f646e1:faa8daf700000000). See 
{{variant-functions-ir.cc:397-398}}. Valid data is reported as corrupt.

Only fields whose name-order successor is stored *earlier* fail, so the other 
fields read fine and the wrong answers look plausible. With Spark-written data, 
any object whose keys are not in sorted (byte) order is affected. VARIANT is 
new in 5.0.0, so no release is affected yet; this should be fixed before 5.0.0.

h3. Steps to reproduce
Spark 4.1.3 + Iceberg 1.11.0 ({{ath}} is the Spark catalog name of the Iceberg 
REST catalog):
{code:sql}
CREATE NAMESPACE IF NOT EXISTS ath.zz_jira_variant;
CREATE TABLE ath.zz_jira_variant.variant_order (id INT, v VARIANT) USING iceberg
  TBLPROPERTIES ('format-version'='3');
INSERT INTO ath.zz_jira_variant.variant_order VALUES
  (1, parse_json('{"a":1,"b":2}')),
  (2, parse_json('{"b":2,"a":1}')),
  (3, parse_json('{"a":1,"c":3,"b":2}')),
  (4, parse_json('{"a":{"y":1,"x":2},"b":0}'));
{code}
Impala, reading the same catalog (impalad {{--catalog_config_dir}}):
{code:sql}
SELECT id, variant_to_json(v) AS doc, variant_to_json(variant_get(v, '$.a')) AS 
a,
       variant_get(v, '$.b', 'int') AS b, variant_get(v, '$.a.x', 'int') AS a_x
FROM zz_jira_variant.variant_order ORDER BY id;
{code}

h3. Expected result
The Spark answer, using {{to_json}} for {{variant_to_json}}. DuckDB 1.5.5 
({{read_parquet}} on the same data files) gives the same answer.
{noformat}
id | doc                       | a             | b | a_x
1  | {"a":1,"b":2}             | 1             | 2 | NULL
2  | {"a":1,"b":2}             | 1             | 2 | NULL
3  | {"a":1,"b":2,"c":3}       | 1             | 2 | NULL
4  | {"a":{"x":2,"y":1},"b":0} | {"x":2,"y":1} | 0 | 2
{noformat}

h3. Actual result (master 3d4d47dc30, RELEASE; query 
4e442488d6e7bfa8:41812d4b00000000)
{noformat}
id | doc           | a    | b    | a_x
1  | {"a":1,"b":2} | 1    | 2    | NULL
2  | NULL          | NULL | 2    | NULL
3  | NULL          | 1    | NULL | NULL
4  | NULL          | NULL | 0    | NULL
{noformat}
The query log has four warnings {{UDF WARNING: Failed to read object field}}. 
{{SELECT count(*) ... WHERE variant_get(v, '$.b', 'int') IS NULL}} returns 1 
(row 3), with no warning (ac475cede0ffbae6:d7c0dffc00000000). The query ids in 
Problem are from a second copy of the table (same statements, identical output: 
f642e4eabe18623a:494755eb00000000). In row 3, "c" is stored out of order too 
but reads fine (484b82cd94a8843a:bd69e7b200000000).

Row 2 as stored by Spark (read with pyarrow): metadata {{01 02 00 01 02 62 61}} 
(dictionary \["b", "a"\], sorted_strings=0); value {{02 02 01 00 02 00 04 0c 02 
0c 01}}: header, 2 fields, field ids \[a, b\] (name order), offsets \[2, 0, 
4\], then the values b=2 (at 0) and a=1 (at 2). For "a", Impala reads start=2, 
end=offset\[1\]=0 and gives up.

h3. Which shapes fail
16 Spark-written test documents: 11 read wrong, 5 right. A field fails exactly 
when {{offset\[i+1\] < offset\[i\]}} (checked with pyarrow); every other field 
reads right.
* Wrong: reversed keys ({{\{"c":3,"b":2,"a":1\}}}, the spec's own example), one 
key out of place, byte order ({{\{"a":1,"B":2\}}}), nested objects, objects 
inside arrays, 2- and 3-byte field offsets, 300 reversed fields ({{is_large}}).
* Right: sorted keys, one field, the empty object, top-level arrays. Offset 
sizes (1 to 4 bytes) and {{is_large}} do not change the outcome; 4-byte offsets 
were checked separately, with a value over 16 MiB 
(e84c83d93ca74d83:c77bf8a700000000).
* After {{parse_json(to_json(v))}} (Spark's {{to_json}} emits sorted keys): 
16/16 right.

Benchmark (Iceberg Athletics Pole Vault, table {{variant.events_plain}}, 100M 
rows at sf1): 11 of 30 {{plain}} queries are wrong at tiny and sf1; DuckDB 
1.5.5 (tiny) and StarRocks 4.1.4 (sf1) answer all 30 right, Trino 483 (sf1) 28 
(2 unsupported). {{plain.q1}}: {{sum(variant_get(payload, '$.duration', 
'int'))}} is 3,597,278,124 instead of 179,940,024,471 
({{results/variant/impala5-rest/sf1/}}). {{SELECT id, payload ... WHERE id = 
0}} fails (df4bc2f51a85930f:e962ca6d00000000), and {{variant_to_json(payload)}} 
is NULL in all 100,000,000 rows (3544252c08c50795:4b2a653a00000000). Rows 
readable through {{try_variant_get}} compared with {{get_json_object}} on the 
JSON copy (8248e86cf82ed174:a9d8ddbe00000000, 
1846e46de6a44e55:c3ef16a800000000):
{noformat}
path                                                        VARIANT       JSON
country, event_type, ts_us, user.id, metrics.timing.load_ms 100,000,000   
100,000,000
coupon                                                        2,001,260     
2,001,260
duration                                                      1,998,857   
100,000,000
error.code                                                            0     
1,998,857
items[0].qty                                                          0    
22,999,379
referrer                                                              0    
29,994,559
tags[0]                                                               0    
74,999,893
device, metrics.flags.is_bot                                          0   
100,000,000
{noformat}
"duration" is readable only when an "error" key follows it. For ids < 
5,000,000, the rows where duration is readable are exactly the rows that have 
"error" (0 mismatches, d641b45f97859c58:f24a3b1a00000000).

h3. Analysis (be/src/runtime/variant-value.cc at master 3d4d47dc30)
* {{VariantValue::FieldFromLayout()}} (lines 312-321) reads {{start = 
offset\[index\]}} and {{end = offset\[index + 1\]}}, then line 318: {{if (end < 
start || end > layout.data_len) return false;}}. {{GetFieldByName()}} (line 
307) and the JSON serializer (lines 658-660, "Failed to read object field") 
both use it.
* Per the spec, {{offset\[i+1\]}} is where the *next field in name order* 
starts, not where field i ends. The reference reader does not use it: 
parquet-java {{Variant.getFieldAtIndex()}} slices from {{dataStart + offset}}, 
and {{VariantUtil.valueSize()}} takes a value's size from its own header.
* Introduced by IMPALA-15221 (7fe7e1b35a). Its description lists "next_offset - 
field_offset can unsigned-underflow" as a corruption symptom, but for objects 
this is valid data. Before it, IMPALA-15067's {{GetFieldByIndex()}} computed 
the length with unsigned wrap-around (e5727bb717 {{variant-value.cc:308-314}}); 
by code reading the values still decoded, because every decoder sizes a value 
from its header. So the regression is in SELECT of the column: IMPALA-15052 
(480cdc593d, committed 2026-07-29) returned such objects until IMPALA-15221 was 
committed (2026-07-31). {{variant_get}} and the {{variant_to_json}} builtin 
were both added by IMPALA-15057 (1e3598ce49), after IMPALA-15221, and never 
worked on such data.
* The error is silent. {{GetFieldByName()}} returns false both for "no such 
field" and for "unreadable field". {{NavigatePath()}} (line 440) passes that 
on, and {{Navigate()}} ({{be/src/exprs/variant-functions-ir.cc:77}}) maps it to 
{{SQL_NULL}} ("segment missing").
* Why tests missed it: both {{BuildObject()}} helpers 
({{be/src/util/variant-util-test.cc:87}}, 
{{be/src/exprs/variant-functions-test.cc:106}}) always store values in 
field-list order. The Trino-written test tables {{trino_variant}} and 
{{trino_nested_variant}} have 19 objects, none with a decreasing offset 
(pyarrow). The {{ToJson}} unit test ({{variant-util-test.cc:356-371}}) even 
builds an object whose field ids are not in name order, which the spec does not 
allow. IMPALA-15221 added a {{ValueCorruptionRejected}} case, "Object with 
non-monotonic field offsets \[5, 0\]" ({{variant-util-test.cc:939-941}}). It 
must stay rejected, but for another reason: the field starts at 5, which is the 
end of the 5-byte data region. Keep the case and fix its comment.

Suggested fix:
* Add a {{ValueSize(data, avail, &size)}} helper that computes a value's size 
from its header, like parquet-java {{valueSize()}}. Primitives have fixed 
sizes; a short string is 1+len; a long string or binary is 5+len; an object or 
array is its header and tables plus its last offset. Bounds-check the result 
against {{avail}}. Compute header and table sizes in 64 bits, as 
{{ParseObjectLayout()}} does. Reject unknown primitive type ids (21-63), but 
size the types Impala does not render yet (TIMESTAMPTZ, TIME, 
TIMESTAMPTZ_NANOS: 9 bytes; UUID: 17 bytes).
* In {{FieldFromLayout()}}, check {{start < data_len}} and take the length from 
{{ValueSize(data + start, data_len - start)}}; do not use {{offset\[i+1\]}}. 
The {{start < data_len}} check keeps the \[5, 0\] test case rejected.
* Defensive, optional: size array elements the same way 
({{ElementFromLayout()}}, lines 379-388, check at 385). Spark writes array 
elements in order, but the spec defines array offsets only as start positions.
* Report an unreadable field as corrupt (an error from {{variant_get}}, a 
warning from {{try_variant_get}}), not as a missing field. {{GetFieldByName()}} 
and {{NavigatePath()}} need a third result for this, because a missing field or 
a path step into a non-object must still give NULL. Land this together with the 
{{FieldFromLayout()}} change: {{start < data_len}} rejects the zero-length 
field in {{EmptyValueAtPath}} ({{be/src/exprs/variant-functions-test.cc:556}}). 
Today that is an error from the strict {{variant_get}}; with the 
{{FieldFromLayout()}} change alone it would become NULL.
* Tests: a unit test with the row 2 bytes above. Let both {{BuildObject()}} 
helpers store values in a given order, so that {{variant_get}} and 
{{try_variant_get}} are covered too, and make the {{ToJson}} test spec-valid. 
Add a Spark-written Parquet file with reversed, nested, byte-order, 
{{to_variant_object}} and 2-/3-byte-offset objects (see IMPALA-15231).



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to