Skip to content

[WIP] HIVE-29834: Iceberg: Answer min/max/count aggregates from column statistics - #6716

Open
deniskuzZ wants to merge 11 commits into
apache:masterfrom
deniskuzZ:col_stats_stats_opt
Open

[WIP] HIVE-29834: Iceberg: Answer min/max/count aggregates from column statistics#6716
deniskuzZ wants to merge 11 commits into
apache:masterfrom
deniskuzZ:col_stats_stats_opt

Conversation

@deniskuzZ

@deniskuzZ deniskuzZ commented Aug 21, 2026

Copy link
Copy Markdown
Member

What changes were proposed in this pull request?

Why are the changes needed?

Does this PR introduce any user-facing change?

How was this patch tested?

depends on #6707

…berg virtual column plumbing

Adds VirtualColumn.PARTITION_NAME, materialized by the Iceberg readers: vectorized via
HiveBatchIterator, row mode via PartitionInfo -> IOContext -> MapOperator.

Refactors the read path to take everything the file already carries from the scan task
instead of the per-record constants map: spec id, partition hash, file path, partition
name, first row id and file sequence number are computed once per task in both
VirtualColumnAwareIterator and HiveBatchIterator. Drops the now unread _partition column
from the ACID read schema, along with the write-only VIRTUAL_COLS_TO_META_COLS map and the
per-record helpers it fed.

Fixes VectorizedParquetRecordReader taking a column type from the job column list while
indexing it with a batch column number: the two orders diverge past the data columns
because the batch drops virtual columns the readers cannot fill.
…ead of a virtual column

The partition key a merge task carries is read back only by HiveIcebergDeleteWriter under
isMergeTask: an ordinary delete derives the key from the row data, and a merge task cannot
because it reads delete files, whose records hold only a path and a position.

Declaring it as a virtual column put an always empty string into every DELETE, UPDATE and
MERGE record and into every Iceberg plan. It now lives in a merge specific serde layout that
IcebergMergeRecordReader and HiveIcebergSerDe select on the merge task flag, so the ordinary
delete record is one column shorter and the plans no longer estimate it.
@deniskuzZ
deniskuzZ force-pushed the col_stats_stats_opt branch from 27fc967 to d0e8fea Compare August 23, 2026 06:26
@deniskuzZ deniskuzZ changed the title [WIP] Let StatsOptimizer answer from a storage handler's column statistics [WIP] HIVE-29834: Iceberg: Answer min/max/count aggregates from column statistics Aug 23, 2026
@deniskuzZ
deniskuzZ force-pushed the col_stats_stats_opt branch from d0e8fea to be100ec Compare August 23, 2026 11:05
deniskuzZ and others added 9 commits August 24, 2026 15:35
…each write in one place

A partition scoped gather ran one query per partition spec, each grouped by that
spec's own transforms. The rows of every spec already carry the name of the
partition they belong to, so one scan grouped by that name gathers all of them, and
the map of specs it took to drive the old shape is gone.

What a write then does to the stored statistics - replace them, merge into them, or
leave them alone - was settled across writeColStats, shouldRewriteColStats,
canProvideColStats and the merge itself. ColStatsWritePolicy now states every case
of that decision over the facts of the write alone, so a test can cover it without
a table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… keep a branch's on the branch

hive.iceberg.stats.collect.partlevel (default false) chooses the granularity for
Iceberg tables, apart from the generic hive.analyze.stmt.collect.partlevel.stats.
ANALYZE produces partition-level statistics; every other write maintains
table-level ones.

The read path keys on the calling API rather than on session config, so a
granularity that changed between a write and a read cannot surface a blob of the
wrong shape.

A write to a branch stores its statistics on that branch's snapshot and leaves the
table-scoped metastore row alone, including on the footer scan that
ANALYZE ... COMPUTE STATISTICS uses.

A whole-table INSERT OVERWRITE of a partitioned table now replaces its column
statistics. It skipped before, because the snapshot summary does not distinguish a
whole-table overwrite from a partition-scoped one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…em up under

A write derived partition names its own way, so the statistics it stored were keyed
differently from the way a read asks for them, and a read found nothing for a
partition a write had just described. Both sides now take the name from the Iceberg
spec.

A write reaching some partitions, and an ANALYZE naming them, stand in for those
partitions alone rather than for the whole table. A CTAS gathers partition-level
statistics for the table it creates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… was written for

A statistics file holds only what was valid for the snapshot it was written for,
so statsFile.snapshotId() is the only anchor a read needs. A merge carries an
entry forward only when no write since changed its partition, and a read serves
what nothing changed after the file; partsFound reports how much of the scan is
covered, so a caller needing an exact answer refuses and one estimating reports
PARTIAL.

Ordering by position on the ancestry rather than by sequence number fixes format
version 1 tables, which number every snapshot 0 and so judged every partition
fresh no matter what had been written since.

A compaction reads the settings its table carries for it, and did so only after the query
it runs and the statistics it gathers had already been settled from the settings it
did not have. They are in hand first now, so a table asking to keep statistics per
partition is heard by the compaction of one of its partitions, and one that asks for
nothing of the sort still has that compaction leave its statistics alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tition of no value as Hive does

A partition scoped ANALYZE scoped its scan by a value predicate, which names rows
rather than partitions. On a table whose partitioning had changed, that predicate
reached rows an older spec still held in a partition of its own, and the group that
formed described the whole of that partition by the few rows it saw. A table evolved
from unpartitioned answered max(id) as 7 where the truth was 100.

The scan is now scoped by partition identity, as compaction scopes its rewrite: the
values name partitions of the current spec, and the spec's own transforms applied to
those values keep every row of each and still prune what the scan reads. A partition
of an older spec keeps the statistics it has, which no write since could have
changed, because a write lands in the current spec.

Rendering a partition of no value as the text "null" also gave it the name a value of
that text takes, so the two shared a name and a statistics blob. Hive names it
__HIVE_DEFAULT_PARTITION__ and Iceberg's own Conversions reads that name back as no
value, so both now say so, which lets a statement name that partition too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…istics

StatsOptimizer fetched partition column statistics straight from the
metastore, which cannot hold them for an Iceberg table: PART_COL_STATS rows
require a PARTITIONS row that Iceberg never creates. Aggregates over a
partitioned Iceberg table therefore always executed, even with statistics
available in Puffin files.

Fetch through Hive.getAggrColStatsFor, which consults the storage handler,
mirroring what getRowCnt already does for row counts. The statistics arrive
aggregated over the pruned partitions rather than one entry per partition;
the callers folded them with min, max or a sum, so merging first gives the
same answer and the folds collapse.

Accuracy is asked of the component that owns it. The metastore's marker only
records Hive's own writes, while an Iceberg table may be written by other
engines, so areColumnStatsUptoDate is added to HiveStorageHandler and
answered from table metadata by the Iceberg handler. The default keeps the
metastore marker, so other handlers are unchanged, and the Iceberg override
defers to the metastore when hive.iceberg.stats.source says the statistics
live there.

A table that keeps statistics per partition has no table-wide verdict to
give: they describe no partition in particular. The partitioned non-native
path therefore asks only whether the table provides column statistics at all,
and the aggregate - the one place told the partitions the scan pruned to -
reports how many of them it found, which is what stops the rewrite from
describing a subset.

count(col) needs a row count as well as the column's null count, and read it
from partition parameters, which a storage handler does not keep, so it gave
up on every partitioned non-native table before reaching the column
statistics at all. It now asks getRowCnt, which consults the handler and is
given the pruned partitions; a count no handler can determine exactly, such
as one covering delete files, still stops the rewrite.
…ler's statistics

An unpartitioned table's aggregates were fetched with the metastore client
directly, which for a table whose statistics a handler keeps returns nothing:
ColStatsProcessor writes the values to the handler and only the accuracy marker
to the metastore. The accuracy check above the fetch therefore guarded a fetch
that could not have served them, and min, max and count over an unpartitioned
Iceberg table always read the data.

Fetch through Hive.getTableColumnStatistics, which asks the handler when it can
provide, mirroring what the partitioned path already does. A native table takes
the same call it took before, resolving its snapshot and write id list the same
way. The accuracy check now guards statistics that exist, which is what stops a
write another engine made from being answered from numbers that predate it.

The third of the three fetches took the first element without checking that
there was one; a handler may legitimately return none.
The statistics of one partition carry every column of it, and the fetch was
made once per aggregate, so a query over a thousand columns opened the
statistics file a thousand times and discarded all but one column of each
partition it had just deserialised.

Collect the columns the aggregates read before walking them and fetch once for
all of them, leaving each aggregate a lookup. A native table keeps its check of
each column against each partition's own marker; only the fetch is shared.
…ne fetch

The rewrite asks for every aggregate's column at once instead of once per
aggregate, and only when an aggregate it can answer actually asks, so a query
holding a sum or a count(distinct) pays nothing. A scan of an unpartitioned
table is answered the same way, and one pruned to no partitions folds to the
identity of each aggregate rather than declining.

An unpruned scan of a table whose handler keeps whole-table statistics is
answered from those directly: they describe exactly the rows read, so there is
nothing to aggregate. The row count of an unpartitioned scan is asked of the
table rather than read off its parameters, which describe the current snapshot
however the scan resolved a branch, a tag or a version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants