Zoltán Borók-Nagy created IMPALA-15443:
------------------------------------------
Summary: Parquet stats filtering decodes INT32/INT64 min/max at
the table's wider DECIMAL width (decimal widening, int->decimal), silently
dropping rows
Key: IMPALA-15443
URL: https://issues.apache.org/jira/browse/IMPALA-15443
Project: IMPALA
Issue Type: Bug
Components: Backend
Reporter: Zoltán Borók-Nagy
h3. Problem
If a Parquet file stores a column as INT32 or INT64, but the table's DECIMAL
type needs more bytes (INT32 file read as precision >= 10, INT64 file read as
precision >= 19), Impala decodes the file's min/max statistics at the *table's*
width. The upper bytes of the decoded value are not taken from the file, so
they depend on memory contents and answers can differ between executions.
Row-group statistics and the page index (column index) both use these values,
so row groups and pages that contain matching rows are skipped and queries
silently return too few rows. With default options, some tables gave the same
wrong answer in every try and others varied. With negative values the answer
was wrong in almost every try (4.5.2 and master; one master run of {{t_neg}}
with {{PARQUET_READ_PAGE_INDEX=false}} was right in 2 of 50 tries).
This is the normal state after a decimal precision widening:
* Iceberg type promotion {{decimal(10,2) -> decimal(20,2)}} (spec-valid). Old
files keep INT64 decimal(10,2).
* Plain Parquet tables after {{ALTER TABLE ... CHANGE amt amt DECIMAL(20,2)}}
(the IMPALA-7087 path).
* Master (5.0.0) only: INT/BIGINT columns read as DECIMAL (IMPALA-13625), e.g.
after {{ALTER TABLE t CHANGE c c DECIMAL(10,0)}}. IMPALA-13625 requires
precision >= 10 for INT32 and >= 19 for INT64, so every such read uses a wider
slot.
Only files that store the column as INT32/INT64 *and* carry column orders are
affected: parquet-mr 1.17.1 (as used by Spark 4.1.3 + Iceberg 1.11.0), pyarrow
with {{store_decimal_as_integer=True}}, and on master Impala-written INT/BIGINT
columns read as DECIMAL through IMPALA-13625. Files without column orders are
not affected, because their decimal stats are ignored. Impala-written DECIMAL
columns (FIXED_LEN_BYTE_ARRAY) are not affected. The values themselves are read
correctly; only stats-based skipping is wrong.
h3. Steps to reproduce (no Iceberg needed)
{code:python}
import decimal, pyarrow as pa, pyarrow.parquet as pq
vals = [decimal.Decimal(f"{i}.00") for i in range(-5, 5)]
t = pa.table({"id": pa.array(range(1, 11), pa.int64()), "amt": pa.array(vals,
pa.decimal128(10, 2))})
pq.write_table(t, "decimal_int64_negative.parquet",
store_decimal_as_integer=True,
write_page_index=True, max_rows_per_page=2,
use_dictionary=False, compression="NONE")
# amt: INT64 Decimal(precision=10, scale=2), min -5.00, max 4.00
{code}
Put the file into an empty directory, then in Impala:
{code:sql}
CREATE EXTERNAL TABLE hive_neg (id BIGINT, amt DECIMAL(20,2)) STORED AS PARQUET
LOCATION '<dir>';
SELECT count(*) FROM hive_neg WHERE amt < 0; -- expected 5
SELECT count(*) FROM hive_neg WHERE amt = -3.00; -- expected 1
{code}
Iceberg variant (Spark SQL, then register the table in Impala):
{code:sql}
CREATE TABLE t_neg (id BIGINT, amt DECIMAL(10,2)) USING iceberg TBLPROPERTIES
('format-version'='2');
INSERT INTO t_neg SELECT id, CAST(id AS DECIMAL(10,2)) FROM range(-5, 5, 1, 1);
-- INT64 decimal(10,2)
ALTER TABLE t_neg ALTER COLUMN amt TYPE DECIMAL(20,2);
INSERT INTO t_neg SELECT id, CAST(id AS DECIMAL(20,2)) FROM range(-5, 5, 1, 1);
-- FIXED_LEN_BYTE_ARRAY decimal(20,2)
-- Spark: SELECT count(*) FROM t_neg WHERE amt < 0 -> 10
{code}
{code:sql}
-- Impala, for a Hadoop-catalog table (use the HMS equivalent otherwise)
CREATE EXTERNAL TABLE t_neg STORED AS ICEBERG LOCATION '<table dir>'
TBLPROPERTIES('iceberg.catalog'='hadoop.tables');
SELECT count(*) FROM t_neg WHERE amt < 0; -- expected 10
{code}
h3. Actual result (Impala 4.5.2; master 3d4d47dc30 RELEASE build gives the same
unless noted)
* {{hive_neg}}: {{amt < 0}} returns *0* (50/50 tries, also on master), {{amt =
-3.00}} returns *0*. {{SELECT *}} returns the correct values. With
{{PARQUET_READ_STATISTICS=false}} the answers are 5 and 1.
* {{t_neg}}: {{amt < 0}} returns *5* instead of 10 (50/50 on master); {{amt + 0
< 0}} (no stats conjunct) returns 10. Profile: {{NumRowGroups: 2}},
{{NumStatsFilteredRowGroups: 1}}. {{amt = -3.00}} returns 1 instead of 2.
* On a larger Iceberg table (100k rows per file, 5 pages per column chunk),
{{amt < 50000.00}} returns 50278 instead of 100260 (the whole old file is
skipped). On 4.5.2 the answer varied between executions of the same query: in
one try only 4 of the 5 pages of the old file were skipped (60274), and the
original benchmark query returned 4 different answers in 4 tries.
* Master: two independently generated tables of this shape skipped the whole
old file in every default-options try (100/100 and 330/330); with
{{PARQUET_READ_PAGE_INDEX=false}} they were wrong in 12/50 and 19/100 tries.
The 4-of-5-page skip showed up on another promoted column (INT32 decimal(9,2)
-> DECIMAL(12,2): 59719 instead of 99697, {{NumStatsFilteredPages: 4}}). A
5-file table shaped like the benchmark returned 4 different wrong answers in 40
tries, never the right one.
* {{PARQUET_READ_PAGE_INDEX=false}} does *not* fix it in general, because
row-group statistics are decoded the same way.
h3. Analysis (4.5.2 line numbers; master 3d4d47dc30 in brackets)
* {{ColumnStatsReader::ReadFromString()}}, case {{TYPE_DECIMAL}}
({{be/src/exec/parquet/parquet-column-stats.cc:164-177}} [167-180]), switches
on {{col_type_.GetByteSize()}}, i.e. the *table* type. For DECIMAL(20,2) it
calls {{DecodeDecimal<Decimal16Value>}} with the file's physical type INT64.
* That ends in the generic
{{ParquetPlainEncoder::DecodeNoBoundsCheck<Decimal16Value, INT64>}}
({{be/src/exec/parquet/parquet-common.h:468-478}} [469-479]), which does
{{memcpy(v, buffer, sizeof(InternalType))}}: it copies 16 bytes from an 8-byte
stat value. Its own comment says the generic template is only for no conversion
or narrowing conversions; there is no specialization for Decimal8Value/INT32,
Decimal16Value/INT32 or Decimal16Value/INT64.
* The extra bytes come from whatever follows the stat value in memory, most
likely the {{std::string}} small-string buffer (not confirmed in a debugger).
This explains the nondeterminism. For an 8-byte stat, byte 8 is then the
string's NUL terminator, so the upper word can never be the all-ones sign
extension, which is why negative values fail almost always.
* The data path is correct: {{CreateDecimalColumnReader()}}
({{parquet-column-readers.cc:1677}} [1895]) decodes by the physical type and
widens with {{ParquetDataConverter}}.
* {{ReadFromStringsBatch()}} (page-level min/max runtime filters) has the same
flaw, including for INT32 -> BIGINT and FLOAT -> DOUBLE
({{DecodeBatchOneBoundsCheckFastTrack<int64_t>/<double>}},
{{parquet-column-stats.cc:210/223}} [213/226]). It gives wrong join results
with explicit PAGE-level min/max filter settings, and also with *default* query
options when the widened column is the table's leading SORT BY column: with
{{MINMAX_FILTER_SORTED_COLUMNS=true}} (the default) Impala creates a min/max
filter for that column (IMPALA-10709), and the planner then runs it with
{{MINMAX_FILTER_THRESHOLD=0.5}} at PAGE level. Master, default options, plain
Parquet SORT BY table joined with {{VALUES (-3),(-2)}}: 0 rows instead of 2 in
30/30 tries for INT32 read as BIGINT, 30/30 for FLOAT read as DOUBLE, and 17/30
for DECIMAL(10,2) read as DECIMAL(20,2). Same-width controls were correct. An
Iceberg table promoted int -> long / float -> double with an Impala SORT BY
returned 2 instead of 4 in 20/20.
* Reachable since IMPALA-7087 / IMPALA-8131 (commit 9d46255739, 4.1.0). Before
that, {{ParquetMetadataUtils::ValidateColumn()}} rejected INT32/INT64 decimals
whose width differed from the table type; the table-width decode in
{{ReadFromString()}} is older. 4.1.0 through 4.4.x are likely affected but were
not tested. Master 3d4d47dc30 reproduces all steps above.
* IMPALA-13625 (reading integer columns as DECIMAL, 5.0.0) reaches the same
decode (reproduced on master). Impala-written table (id BIGINT, c1 INT, c2
BIGINT) with values -5..4, then {{ALTER TABLE ... CHANGE c1 c1 DECIMAL(10,0)}}
and {{CHANGE c2 c2 DECIMAL(19,0)}}: {{c1 < 0}} returns 0 (expected 5), {{c1 <
3}} returns 0 (expected 8), {{c2 < 0}} returns 0 (expected 5), 30/30 each.
Correct with {{PARQUET_READ_STATISTICS=false}}.
Suggested fix: decode decimal stats at the file's width (Decimal4Value for
INT32, Decimal8Value for INT64) and then {{ConvertSlot()}} to the table width,
like the column readers do; use the BIGINT/DOUBLE fast track only for matching
physical types; add {{DCHECK_LE(sizeof(InternalType), byte_size)}} to the
generic {{DecodeNoBoundsCheck}}.
h3. Workaround
{{SET PARQUET_READ_STATISTICS=false}} (also fixes the min/max runtime filter
join cases), or rewrite the old data files with the new type (verified with
Spark {{rewrite_data_files}}). For the sorted-column join case only,
{{MINMAX_FILTER_SORTED_COLUMNS=false}} also helps.
{{PARQUET_READ_PAGE_INDEX=false}} is not a workaround.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)