Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
e16d7d4
Add function to compute parquet file metadata
maxdebayser Jun 13, 2023
96adb31
Addition of docstring and extra parameter to avoid reading the file
maxdebayser Jun 13, 2023
ce8a5df
Refactor the statistics computation entirely to use pyarrow metadata
maxdebayser Jun 26, 2023
1be86e7
Merge remote-tracking branch 'iceberg/master' into compute_parquet_stats
maxdebayser Jun 26, 2023
e6c3f94
Appease pre-commit hooks
maxdebayser Jun 26, 2023
e4e0b2b
Fix temporary path
maxdebayser Jun 26, 2023
a5f4ef9
Merge remote-tracking branch 'iceberg/master' into compute_parquet_stats
maxdebayser Jul 6, 2023
ac23783
Merge remote-tracking branch 'iceberg/master' into compute_parquet_stats
maxdebayser Jul 10, 2023
ed27875
Make the metrics mode configurable as documented here: https://iceber…
maxdebayser Jul 10, 2023
de46bef
Initialize binary serializers only once
maxdebayser Jul 10, 2023
5ae5b2e
Log arrow not implemented exception
maxdebayser Jul 10, 2023
33218eb
Fix None comparison expression
maxdebayser Jul 10, 2023
4975e99
Add map column to test data
maxdebayser Jul 10, 2023
98c93ca
Moving pyarrow specific code to io.pyarrow
maxdebayser Jul 10, 2023
a480539
type annotation
maxdebayser Jul 10, 2023
a0f44d5
Refactor the stats collection using the pyarrow visitor
maxdebayser Jul 12, 2023
3e738fe
Merge remote-tracking branch 'iceberg/master' into compute_parquet_stats
maxdebayser Jul 12, 2023
1d5cbbf
Clean redundant code and add warning message to the log
maxdebayser Jul 13, 2023
f2f001e
Merge remote-tracking branch 'iceberg/master' into compute_parquet_stats
maxdebayser Jul 13, 2023
dc34698
Address some of the review comments
maxdebayser Jul 18, 2023
e233f54
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Jul 18, 2023
8dda3fa
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Jul 28, 2023
820938a
Add tests to check of the number of columns found by the statistics
maxdebayser Jul 28, 2023
9e114c8
We don't want to truncate numeric data types
maxdebayser Jul 28, 2023
e7a6fb8
Verify match of Iceberg types with Parquet physical types
maxdebayser Jul 30, 2023
8ad7f3f
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Jul 30, 2023
c965a3e
Fix truncation of upper bounds
maxdebayser Jul 31, 2023
1ba46d6
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Jul 31, 2023
44dbb0c
Transform asserts to ValueErrors
maxdebayser Aug 1, 2023
74a3d6a
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 1, 2023
cdc6eb8
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 4, 2023
5b4c2f2
Add review suggestions
maxdebayser Aug 4, 2023
ec5fcaa
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 7, 2023
4ee5036
Address simple code style review comments
maxdebayser Aug 7, 2023
45abc6d
Fix potential null write
maxdebayser Aug 7, 2023
7ee1ef0
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 9, 2023
5898f3f
Apply function name refactoring
maxdebayser Aug 9, 2023
e7edf0b
Move pyarrow statistics tests to a new file
maxdebayser Aug 9, 2023
6f7bd98
Disable stats computation for nested types
maxdebayser Aug 9, 2023
05579ff
Modularize the fill_parquet_file_metadata function
maxdebayser Aug 9, 2023
aae1118
Allow metrics modes to have extra whitespace but not other trailing
maxdebayser Aug 9, 2023
11b5d3a
Move upper bound truncation logic to another file
maxdebayser Aug 9, 2023
4332a95
Be defensive with regards to missing row group statistics
maxdebayser Aug 9, 2023
09c5955
Add tests for structs
maxdebayser Aug 9, 2023
c131b58
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 10, 2023
5e01924
Remove special treatment of UUIDType
maxdebayser Aug 10, 2023
7f768eb
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 18, 2023
be70fd5
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 21, 2023
0be438e
Rely on parquet column path rather than column order
maxdebayser Aug 21, 2023
8226a01
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Aug 25, 2023
867ea80
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Sep 12, 2023
ebb604a
Change mood to imperative to appease linter
maxdebayser Sep 12, 2023
640f885
Merge branch 'master' of https://github.com/apache/iceberg into compu…
maxdebayser Sep 15, 2023
acf6d4f
Factor out the logic to obtain the current table schema
maxdebayser Sep 16, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
333 changes: 333 additions & 0 deletions python/pyiceberg/utils/file_stats.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,333 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

import struct
from typing import (
Any,
Dict,
List,
Union,
)

import pyarrow.lib
import pyarrow.parquet as pq

from pyiceberg.manifest import DataFile, FileFormat
from pyiceberg.schema import Schema, SchemaVisitor, visit
from pyiceberg.types import (
IcebergType,
ListType,
MapType,
NestedField,
PrimitiveType,
StructType,
)

BOUND_TRUNCATED_LENGHT = 16
Comment thread
maxdebayser marked this conversation as resolved.
Outdated

# Serialization rules: https://iceberg.apache.org/spec/#binary-single-value-serialization
#
# Type Binary serialization
# boolean 0x00 for false, non-zero byte for true
# int Stored as 4-byte little-endian
# long Stored as 8-byte little-endian
# float Stored as 4-byte little-endian
# double Stored as 8-byte little-endian
# date Stores days from the 1970-01-01 in an 4-byte little-endian int
# time Stores microseconds from midnight in an 8-byte little-endian long
# timestamp without zone Stores microseconds from 1970-01-01 00:00:00.000000 in an 8-byte little-endian long
# timestamp with zone Stores microseconds from 1970-01-01 00:00:00.000000 UTC in an 8-byte little-endian long
# string UTF-8 bytes (without length)
# uuid 16-byte big-endian value, see example in Appendix B
# fixed(L) Binary value
# binary Binary value (without length)
#


def bool_to_avro(value: bool) -> bytes:
return struct.pack("?", value)
Comment thread
maxdebayser marked this conversation as resolved.
Outdated


def int32_to_avro(value: int) -> bytes:
return struct.pack("<i", value)


def int64_to_avro(value: int) -> bytes:
return struct.pack("<q", value)


def float_to_avro(value: float) -> bytes:
return struct.pack("<f", value)


def double_to_avro(value: float) -> bytes:
return struct.pack("<d", value)


def bytes_to_avro(value: Union[bytes, str]) -> bytes:
if type(value) == str:
return value.encode()
else:
assert isinstance(value, bytes) # appeases mypy
return value


class StatsAggregator:
def __init__(self, type_string: str):
Comment thread
maxdebayser marked this conversation as resolved.
Outdated
self.current_min: Any = None
self.current_max: Any = None
self.serialize: Any = None

if type_string == "BOOLEAN":
self.serialize = bool_to_avro
elif type_string == "INT32":
self.serialize = int32_to_avro
elif type_string == "INT64":
self.serialize = int64_to_avro
elif type_string == "INT96":
raise NotImplementedError("Statistics not implemented for INT96 physical type")
elif type_string == "FLOAT":
self.serialize = float_to_avro
elif type_string == "DOUBLE":
self.serialize = double_to_avro
elif type_string == "BYTE_ARRAY":
self.serialize = bytes_to_avro
elif type_string == "FIXED_LEN_BYTE_ARRAY":
self.serialize = bytes_to_avro
else:
raise AssertionError(f"Unknown physical type {type_string}")

def add_min(self, val: bytes) -> None:
if not self.current_min:
Comment thread
maxdebayser marked this conversation as resolved.
Outdated
self.current_min = val
elif val < self.current_min:
Comment thread
maxdebayser marked this conversation as resolved.
Outdated
self.current_min = val

def add_max(self, val: bytes) -> None:
if not self.current_max:
Comment thread
maxdebayser marked this conversation as resolved.
Outdated
self.current_max = val
elif self.current_max < val:
self.current_max = val

def get_min(self) -> bytes:
return self.serialize(self.current_min)[:BOUND_TRUNCATED_LENGHT]

def get_max(self) -> bytes:
return self.serialize(self.current_max)[:BOUND_TRUNCATED_LENGHT]


def fill_parquet_file_metadata(
df: DataFile, metadata: pq.FileMetaData, col_path_2_iceberg_id: Dict[str, int], file_size: int
Comment thread
maxdebayser marked this conversation as resolved.
Outdated
) -> None:
"""
Computes and fills the following fields of the DataFile object.

- file_format
- record_count
- file_size_in_bytes
- column_sizes
- value_counts
- null_value_counts
- nan_value_counts
- lower_bounds
- upper_bounds
- split_offsets

Args:
df (DataFile): A DataFile object representing the Parquet file for which metadata is to be filled.
metadata (pyarrow.parquet.FileMetaData): A pyarrow metadata object.
col_path_2_iceberg_id: A mapping of column paths as in the `path_in_schema` attribute of the colum

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A suggestion. Since we have the Iceberg write schema, we could also easily construct a List[int, int] that will tell the mapping of position to field-id. We have the PreOrderSchemaVisitor where we traverse the schema in order to construct this list. I don't like the key_value specific to PyArrow, and the list will be faster. WDYT?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is why I've added the parquet_schema_to_ids function that uses the SchemaVisitor. If you prefer we can pass the schema to the fill_parquet_file_metadata and compute the mapping internally instead of making the caller of the function do it. The reason I decomposed it was to make testing easier.

The parquet_schema_to_ids uses the path_in_schema column metadata attribute instead of the position because I'm not 100% sure if I can assume anything about the position, whereas using the full column path should be safe.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since Arrow is column-oriented, I'm sure that it will follow the order of the write schema:

image

The reason I try to avoid using too many internal details from PyArrow is that we support PyArrow [9.0.0, 12.0.1] currently. There is no guarantee that all these internals stay the same, therefore I think we should do as much as possible within our own control (also regarding the internal Parquet types, or how the column names are structured).

We already have the write schema, so we can easily filter out the primitive types:

class PyArrowStatisticsCollector(PreOrderSchemaVisitor[List[StatisticsCollector]]):
    _field_id = 0
    _schema: Schema
    _properties: Properties

    def __init__(self, schema: Schema, properties: Dict[str, str]):
        self._schema = schema
        self._properties = properties

    def schema(self, schema: Schema, struct_result: Callable[[], List[StatisticsCollector]]) -> List[StatisticsCollector]:
        return struct_result()

    def struct(self, struct: StructType, field_results: List[Callable[[], List[StatisticsCollector]]]) -> List[StatisticsCollector]:
        return list(chain(*[result() for result in field_results]))

    def field(self, field: NestedField, field_result: Callable[[], List[StatisticsCollector]]) -> List[StatisticsCollector]:
        self._field_id = field.field_id
        result = field_result()
        return result

    def list(self, list_type: ListType, element_result: Callable[[], List[StatisticsCollector]]) -> List[StatisticsCollector]:
        self._field_id = list_type.element_id
        return element_result()

    def map(self, map_type: MapType, key_result: Callable[[], List[StatisticsCollector]], value_result: Callable[[], List[StatisticsCollector]]) -> List[StatisticsCollector]:
        self._field_id = map_type.key_id
        k = key_result()
        self._field_id = map_type.value_id
        v = value_result()
        return k + v

    def primitive(self, primitive: PrimitiveType) -> List[StatisticsCollector]:
        return [StatisticsCollector(
            field_id=self._field_id,
            iceberg_type=primitive,
            mode=MetricsMode.TRUNC
            # schema
            # self._properties.get(f"write.metadata.metrics.column.{schema.find_column_name(self._field_id)}")
        )]

This way we get a nice list of columns that we need to collect statistics for. We have:

@dataclass(frozen=True)
class StatisticsCollector:
    field_id: int
    iceberg_type: PrimitiveType
    mode: MetricsMode

Where we can use the field_id to properly populate the maps, and the iceberg_type to feed into to_bytes to do the conversion (so we don't have to have yet another one for PyArrow).

I did a quick test, and it seems to work:

def test_complex_schema(table_schema_nested: Schema):
    tbl = pa.Table.from_pydict({
        "foo": ["a", "b"],
        "bar": [1, 2],
        "baz": [False, True],
        "qux": [["a", "b"], ["c", "d"]],
        "quux": [[("a", (("aa", 1), ("ab", 2)))], [("b", (("ba", 3), ("bb", 4)))]],
        "location": [[(52.377956, 4.897070), (4.897070, -122.431297)],
                     [(43.618881, -116.215019), (41.881832, -87.623177)]],
        "person": [("Fokko", 33), ("Max", 42)]  # Possible data quality issue
    },
        schema=schema_to_pyarrow(table_schema_nested)
    )
    stats_columns = pre_order_visit(table_schema_nested, PyArrowStatisticsCollector(table_schema_nested, {}))

    visited_paths = []

    def file_visitor(written_file: Any) -> None:
        visited_paths.append(written_file)

    with TemporaryDirectory() as tmpdir:
        pq.write_to_dataset(tbl, tmpdir, file_visitor=file_visitor)

    assert visited_paths[0].metadata.num_columns == len(stats_columns)

metadata to iceberg schema IDs. For scalar columns this will be the column name. For complex types
it could be something like `my_map.key_value.value`
file_size (int): The total compressed file size cannot be retrieved from the metadata and hence has to
be passed here. Depending on the kind of file system and pyarrow library call used, different
ways to obtain this value might be appropriate.
"""
col_index_2_id = {}

col_names = set(metadata.schema.names)

first_group = metadata.row_group(0)

for c in range(metadata.num_columns):
column = first_group.column(c)
col_path = column.path_in_schema

if col_path in col_path_2_iceberg_id:
col_index_2_id[c] = col_path_2_iceberg_id[col_path]
else:
raise AssertionError(f"Column path {col_path} couldn't be mapped to an iceberg ID")

column_sizes: Dict[int, int] = {}
value_counts: Dict[int, int] = {}
split_offsets: List[int] = []

null_value_counts: Dict[int, int] = {}
nan_value_counts: Dict[int, int] = {}

col_aggs = {}

for r in range(metadata.num_row_groups):
# References:
# https://github.com/apache/iceberg/blob/fc381a81a1fdb8f51a0637ca27cd30673bd7aad3/parquet/src/main/java/org/apache/iceberg/parquet/ParquetUtil.java#L232
# https://github.com/apache/parquet-mr/blob/ac29db4611f86a07cc6877b416aa4b183e09b353/parquet-hadoop/src/main/java/org/apache/parquet/hadoop/metadata/ColumnChunkMetaData.java#L184

row_group = metadata.row_group(r)

data_offset = row_group.column(0).data_page_offset
dictionary_offset = row_group.column(0).dictionary_page_offset

if row_group.column(0).has_dictionary_page and dictionary_offset < data_offset:
split_offsets.append(dictionary_offset)
else:
split_offsets.append(data_offset)

for c in range(metadata.num_columns):
col_id = col_index_2_id[c]

column = row_group.column(c)

column_sizes[col_id] = column_sizes.get(col_id, 0) + column.total_compressed_size
value_counts[col_id] = value_counts.get(col_id, 0) + column.num_values

if column.is_stats_set:
try:
statistics = column.statistics

null_value_counts[col_id] = null_value_counts.get(col_id, 0) + statistics.null_count

if column.path_in_schema in col_names:
# Iceberg seems to only have statistics for scalar columns

if col_id not in col_aggs:
col_aggs[col_id] = StatsAggregator(statistics.physical_type)

col_aggs[col_id].add_min(statistics.min)
Comment thread
maxdebayser marked this conversation as resolved.
Outdated
col_aggs[col_id].add_max(statistics.max)

except pyarrow.lib.ArrowNotImplementedError:
pass
Comment thread
maxdebayser marked this conversation as resolved.
Outdated

split_offsets.sort()

lower_bounds = {}
upper_bounds = {}

for k, agg in col_aggs.items():
lower_bounds[k] = agg.get_min()
upper_bounds[k] = agg.get_max()

df.file_format = FileFormat.PARQUET
df.record_count = metadata.num_rows
df.file_size_in_bytes = file_size
df.column_sizes = column_sizes
df.value_counts = value_counts
df.null_value_counts = null_value_counts
df.nan_value_counts = nan_value_counts
df.lower_bounds = lower_bounds
df.upper_bounds = upper_bounds
df.split_offsets = split_offsets


class _IndexByParquetPath(SchemaVisitor[Dict[str, int]]):
"""A schema visitor for generating a parquet path to field ID index."""

def __init__(self) -> None:
self._index: Dict[str, int] = {}
self._field_names: List[str] = []

def before_list_element(self, element: NestedField) -> None:
"""Short field names omit element when the element is a StructType."""
self._field_names.append(element.name)

def after_list_element(self, element: NestedField) -> None:
self._field_names.pop()

def before_field(self, field: NestedField) -> None:
"""Store the field name."""
self._field_names.append(field.name)

def after_field(self, field: NestedField) -> None:
"""Remove the last field name stored."""
self._field_names.pop()

def schema(self, schema: Schema, struct_result: Dict[str, int]) -> Dict[str, int]:
return self._index

def struct(self, _struct: StructType, field_results: List[Dict[str, int]]) -> Dict[str, int]:
return self._index

def field(self, field: NestedField, field_result: Dict[str, int]) -> Dict[str, int]:
"""Add the field name to the index."""
if isinstance(field.field_type, PrimitiveType):
self._add_field(field.name, field.field_id)
return self._index

def list(self, list_type: ListType, element_result: Dict[str, int]) -> Dict[str, int]:
"""Add the list element name to the index."""
self._add_field("list.element", list_type.element_field.field_id)
return self._index

def map(self, map_type: MapType, key_result: Dict[str, int], value_result: Dict[str, int]) -> Dict[str, int]:
"""Add the key name and value name as individual items in the index."""
self._add_field("key_value.key", map_type.key_field.field_id)
self._add_field("key_value.value", map_type.value_field.field_id)
return self._index

def primitive(self, primitive: PrimitiveType) -> Dict[str, int]:
return self._index

def _add_field(self, name: str, field_id: int) -> None:
"""Add a field name to the index, mapping its full name to its field ID.

Args:
name (str): The field name.
field_id (int): The field ID.

Raises:
ValueError: If the field name is already contained in the index.
"""
full_name = name

if self._field_names:
full_name = ".".join([".".join(self._field_names), name])

if full_name in self._index:
raise ValueError(f"Invalid schema, multiple fields for name {full_name}: {self._index[full_name]} and {field_id}")
self._index[full_name] = field_id

def by_path(self) -> Dict[str, int]:
return self._index


def parquet_schema_to_ids(schema_or_type: Union[Schema, IcebergType]) -> Dict[str, int]:
"""Generate an index of field parquet paths to field IDs.

It can be used to compute the mapping pass to `fill_parquet_file_metadata`.

Args:
schema_or_type (Union[Schema, IcebergType]): A schema or type to index.

Returns:
Dict[str, int]: An index of field names to field IDs.
"""
if len(schema_or_type.fields) > 0:
indexer = _IndexByParquetPath()
visit(schema_or_type, indexer)
return indexer.by_path()
else:
return {}
Loading