Repository navigation
Addfiles partition bounds fixes - #40252
Conversation
…ion inference These tests describe the correct behavior and fail on master; the commits that follow fix them one group at a time. Failures at this commit: Bounds (getFileMetrics): - millis and nanos timestamps, nanos time: bounds stored in the file's unit under a micros type, off by 1000x. - millis time and uint32: ClassCastException (Integer to Long) while collecting bounds. Partition inference without a location prefix: - all-null boolean partition column is registered under flag=false, an all-null string column under the string "null", and a file lacking the column under flag=false: the null partition goes through a path string and is parsed back. - all-null timestamp under day(ts) is refused: "Text 'null' could not be parsed". - a column with statistics switched off is registered under flag=false although every value is true; an INT96 column is refused with the same parse error instead of an unknown partition. - an Avro file on a partitioned table fails the pipeline with a NullPointerException (null bound maps). - a millis timestamp file lands in day 19 (1970) instead of 2024-01-01. - with write.metadata.metrics.default=none, an unrelated uint32 column fails the pipeline with a ClassCastException during the partition recollection. testMicrosTimestampBoundsAreUnchanged passes and stays as the control.
Metrics-based partition inference treated a partition source column with no bounds as all null. That holds for a column whose values are all null, but bounds are also missing when statistics are switched off, for INT96 timestamps (Iceberg collects nothing for them) and for Avro files, whose Metrics carry null bound maps. - An Avro file on a partitioned table failed the bundle with a NullPointerException on metrics.lowerBounds().keySet(), so one Avro file failed the job. Null bound maps are now read as empty. - A column without bounds is an unknown partition (error row naming the column and pointing at the location prefix) unless the null partition is proven: the file is empty, the column's null count equals its value count, or the Parquet file does not contain the column at all (a file written before the column existed reads as null everywhere). Turns green: testAvroFileOnAPartitionedTableIsAnUnknownPartition, testPartitionColumnWithoutStatisticsIsAnUnknownPartition, testInt96PartitionColumnIsAnUnknownPartition.
… string
getPartitionFromMetrics returned PartitionKey.toPath() and the DataFile
was built with withPartitionPath, which parses each value back from
text. A null partition value is written as the text "null", so:
- boolean identity partition: Boolean.valueOf("null") files the data
under flag=false, and queries pruning on the partition return wrong
rows;
- string identity partition: the file lands under the string "null";
- int, date and timestamp partitions: the parse fails and the file is
refused ("Text 'null' could not be parsed at index 0").
getPartitionFromMetrics now returns the PartitionKey and the DataFile is
built with withPartition. The location-prefix option still goes through
the path, where Iceberg maps __HIVE_DEFAULT_PARTITION__ to null.
Turns green: the all-null boolean, string and day(timestamp) tests and
testFileLackingThePartitionColumnRegistersUnderTheNullPartition.
When the file's own metrics lack bounds for a partition source column (e.g. write.metadata.metrics.default=none), the partition is inferred from metrics recollected with "full" for the partition columns. Every other column fell back to Iceberg's default mode, so bounds were computed for the whole file, and a column Iceberg cannot collect bounds for (uint32, millis time: ClassCastException) failed the bundle from a code path that only catches UnknownPartitionException. The recollection now sets the default mode to none, so only the partition columns are read. It is also less work on wide files. Turns green: testUnrelatedColumnDoesNotBreakInferenceUnderMetricsModeNone.
Files without field ids resolve through the name mapping, and their column bounds are what partition inference and query pruning read, so they must be encoded in the Iceberg type's unit. Iceberg's converter maps TIMESTAMP/TIME in MILLIS or NANOS to micros types and uint32 to long, but ParquetUtil.footerMetrics stores the file's raw statistic: - TIMESTAMP(MILLIS|NANOS), TIME(NANOS): bounds off by 1000x, silently. A 2024 millis timestamp gets a bound in 1970; day(ts) puts the file in the wrong partition and range predicates prune it away, while the data reads correctly. - TIME(MILLIS) int32 and UINT32: ClassCastException (Integer to Long) while collecting bounds, so the file is routed to errors. Fix: BoundAdjustment, applied in getFileMetrics after the id mapping. Affected int32 columns are presented to Iceberg without their annotation so it computes plain int bounds instead of throwing; every affected lower/upper bound is then rewritten as an 8-byte long in the Iceberg unit (millis x1000; nanos /1000 with lower rounded down and upper up so bounds stay conservative; uint32 via toUnsignedLong). Counts are untouched. Files whose columns need no adjustment take the old path unchanged. Turns green: the six bounds tests' remaining failures and testMillisTimestampFileLandsInItsDayPartition.
…alues Found while fixing the partition inference; not in the fuzz findings. Column bounds ignore nulls, so a partition source column holding true and null has bounds true..true and the file was registered under flag=true. Its null rows belong to the null partition, so a query pruning on the partition silently loses them. Such a file spans two partitions and is now an unknown partition (error row naming the column). A transform that maps every value to null (void) is unaffected: nulls and values land in the same partition. Test and fix together: on the previous commit testPartitionColumnWithNullsAndValuesIsAnUnknownPartition fails with the file registered and no error row.
|
R: @ahmedabu98 |
|
Stopping reviewer notifications for this pull request: review requested by someone other than the bot, ceding control. If you'd like to restart, comment |
| return org.apache.parquet.schema.Types.primitive( | ||
| primitive.getPrimitiveTypeName(), primitive.getRepetition()) | ||
| .id(primitive.getId().intValue()) | ||
| .named(primitive.getName()); |
There was a problem hiding this comment.
AI review complained that this creates an issue when (1) the file has multiple row groups, and (2) one row group's bound is a small unsigned int and the other row group's bound is a very large unsigned int (2^31 and above).
This block recreates the type without preserving the unsigned info, so Iceberg treats it as a signed int. When Iceberg tries merging row groups, it compares their bounds to create one pair of bounds for the file. In this case, the comparison can interpret large unsigned ints (e.g. 2147483649) as negative signed ints (-2147483647) and output incorrect bounds.
We can trust the min/max bounds that Parquet shows for each row group, but we can't trust Iceberg's min/max merge output across row groups in this case. We should still strip the annotation but we'd need to compute the bounds of unsigned int columns ourselves (using Integer.compareUnsigned).
Maybe also include a test with two row groups to confirm the failure and also make sure the fix works as intended
There was a problem hiding this comment.
We now reject a file whose uint32 column holds a value of 2^31 or more. Iceberg's readers read uint32 values as signed, so 3000000000 comes back as -1294967296. Comparing the row-group min/max values as unsigned would give bounds that don't match what readers return.
Below 2^31 signed and unsigned order are the same, so the bounds Iceberg computes are correct and we keep them.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #40252 +/- ##
============================================
+ Coverage 57.29% 58.61% +1.32%
- Complexity 3696 13791 +10095
============================================
Files 1201 2579 +1378
Lines 194397 270227 +75830
Branches 3889 11192 +7303
============================================
+ Hits 111373 158391 +47018
- Misses 79416 105802 +26386
- Partials 3608 6034 +2426
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
* AddFiles: regression tests for column bounds and metrics-based partition inference
These tests describe the correct behavior and fail on master; the commits
that follow fix them one group at a time. Failures at this commit:
Bounds (getFileMetrics):
- millis and nanos timestamps, nanos time: bounds stored in the file's
unit under a micros type, off by 1000x.
- millis time and uint32: ClassCastException (Integer to Long) while
collecting bounds.
Partition inference without a location prefix:
- all-null boolean partition column is registered under flag=false, an
all-null string column under the string "null", and a file lacking
the column under flag=false: the null partition goes through a path
string and is parsed back.
- all-null timestamp under day(ts) is refused: "Text 'null' could not
be parsed".
- a column with statistics switched off is registered under flag=false
although every value is true; an INT96 column is refused with the
same parse error instead of an unknown partition.
- an Avro file on a partitioned table fails the pipeline with a
NullPointerException (null bound maps).
- a millis timestamp file lands in day 19 (1970) instead of 2024-01-01.
- with write.metadata.metrics.default=none, an unrelated uint32 column
fails the pipeline with a ClassCastException during the partition
recollection.
testMicrosTimestampBoundsAreUnchanged passes and stays as the control.
* AddFiles: never guess a partition for a column without bounds
Metrics-based partition inference treated a partition source column
with no bounds as all null. That holds for a column whose values are
all null, but bounds are also missing when statistics are switched off,
for INT96 timestamps (Iceberg collects nothing for them) and for Avro
files, whose Metrics carry null bound maps.
- An Avro file on a partitioned table failed the bundle with a
NullPointerException on metrics.lowerBounds().keySet(), so one Avro
file failed the job. Null bound maps are now read as empty.
- A column without bounds is an unknown partition (error row naming the
column and pointing at the location prefix) unless the null partition
is proven: the file is empty, the column's null count equals its
value count, or the Parquet file does not contain the column at all
(a file written before the column existed reads as null everywhere).
Turns green: testAvroFileOnAPartitionedTableIsAnUnknownPartition,
testPartitionColumnWithoutStatisticsIsAnUnknownPartition,
testInt96PartitionColumnIsAnUnknownPartition.
* AddFiles: set the metrics-inferred partition as values, not as a path string
getPartitionFromMetrics returned PartitionKey.toPath() and the DataFile
was built with withPartitionPath, which parses each value back from
text. A null partition value is written as the text "null", so:
- boolean identity partition: Boolean.valueOf("null") files the data
under flag=false, and queries pruning on the partition return wrong
rows;
- string identity partition: the file lands under the string "null";
- int, date and timestamp partitions: the parse fails and the file is
refused ("Text 'null' could not be parsed at index 0").
getPartitionFromMetrics now returns the PartitionKey and the DataFile is
built with withPartition. The location-prefix option still goes through
the path, where Iceberg maps __HIVE_DEFAULT_PARTITION__ to null.
Turns green: the all-null boolean, string and day(timestamp) tests and
testFileLackingThePartitionColumnRegistersUnderTheNullPartition.
* AddFiles: recollect partition metrics for the partition columns only
When the file's own metrics lack bounds for a partition source column
(e.g. write.metadata.metrics.default=none), the partition is inferred
from metrics recollected with "full" for the partition columns. Every
other column fell back to Iceberg's default mode, so bounds were computed
for the whole file, and a column Iceberg cannot collect bounds for
(uint32, millis time: ClassCastException) failed the bundle from a code
path that only catches UnknownPartitionException.
The recollection now sets the default mode to none, so only the
partition columns are read. It is also less work on wide files.
Turns green:
testUnrelatedColumnDoesNotBreakInferenceUnderMetricsModeNone.
* AddFiles: store column bounds in the unit of the Iceberg type
Files without field ids resolve through the name mapping, and their
column bounds are what partition inference and query pruning read, so
they must be encoded in the Iceberg type's unit. Iceberg's converter
maps TIMESTAMP/TIME in MILLIS or NANOS to micros types and uint32 to
long, but ParquetUtil.footerMetrics stores the file's raw statistic:
- TIMESTAMP(MILLIS|NANOS), TIME(NANOS): bounds off by 1000x, silently.
A 2024 millis timestamp gets a bound in 1970; day(ts) puts the file
in the wrong partition and range predicates prune it away, while the
data reads correctly.
- TIME(MILLIS) int32 and UINT32: ClassCastException (Integer to Long)
while collecting bounds, so the file is routed to errors.
Fix: BoundAdjustment, applied in getFileMetrics after the id mapping.
Affected int32 columns are presented to Iceberg without their
annotation so it computes plain int bounds instead of throwing; every
affected lower/upper bound is then rewritten as an 8-byte long in the
Iceberg unit (millis x1000; nanos /1000 with lower rounded down and
upper up so bounds stay conservative; uint32 via toUnsignedLong).
Counts are untouched. Files whose columns need no adjustment take the
old path unchanged.
Turns green: the six bounds tests' remaining failures and
testMillisTimestampFileLandsInItsDayPartition.
* AddFiles: refuse a file whose partition column holds both nulls and values
Found while fixing the partition inference; not in the fuzz findings.
Column bounds ignore nulls, so a partition source column holding true
and null has bounds true..true and the file was registered under
flag=true. Its null rows belong to the null partition, so a query
pruning on the partition silently loses them.
Such a file spans two partitions and is now an unknown partition (error
row naming the column). A transform that maps every value to null (void)
is unaffected: nulls and values land in the same partition.
Test and fix together: on the previous commit
testPartitionColumnWithNullsAndValuesIsAnUnknownPartition fails with the
file registered and no error row.
* fix boundadjustment
* comments
* missing file
* comments
* trigger tests
* move utils, tests
* fixes
* traverse from newest schema
Addfiles partition bounds fixes (#40252)
When a file is registered without
location_prefix,AddFilesinfers its partition from the column bounds (min/max) in the Parquet footer. Today that inference, and the bounds themselves, go wrong in several cases: some files are registered under the wrong partition with no error, some fail the whole pipeline, and some are refused for no good reason. The bounds bug also affects unpartitioned tables, because query planners use the stored bounds to skip files.This PR:
timestamp_nscolumns);Wrong identity partitions are worse than they look: Iceberg's readers take an identity-partitioned column's value from the partition tuple, not from the file. On master, a file written without statistics with
flag = [true, true]is registered underflag=false, and every reader returns[false, false].What changes for users
Partition inference (partitioned tables, no
location_prefix):false, string ->"null", date/int -> refused (Text 'null' could not be parsed)falseIS NULLmisses the null rows,= valuereturns themNullPointerExceptionfails the pipelineyear(ts)month,houroridentityon a timestampFor input string: "2024-01")none, file has a uint32 or millistimecolumnClassCastExceptionfails the pipelineColumn bounds (all tables):
day(ts)is wrongClassCastException, file refusedtime/longcolumn)timestamp_nscolumnThe first commit only adds tests (all but one control fail on master); each later commit makes a group of them pass and names them in its message.
AddFilesMetricsTest). Bounds tests callgetFileMetricsdirectly. Partition tests runAddFilesin aTestPipelineand check the error output and the committedDataFile's partition. Test files have no field IDs, like Spark or pyarrow output, so they resolve through the name mapping.allValuesNull), or a Parquet file without the column (lacksColumn; such a column reads as null). Anything else is an unknown partition. Bound maps are read null-safely for Avro (orEmpty). Worth checking: the null partition is chosen only when every row is null.getPartitionFromMetricsreturns thePartitionKey, and theDataFileis built withwithPartitioninstead ofwithPartitionPath(pk.toPath()). The text round-trip wrote null asnull(parsed back asfalseor the string"null") and couldn't parsemonth,houror timestamp values. Thelocation_prefixpath is unchanged.SerializableDataFilealready carries the partition as JSON, so the null reaches the commit.none, so a column Iceberg can't handle no longer fails the bundle.BoundAdjustment). Iceberg's converter types every Parquet timestamp and time as micros and uint32 as long, but copies the raw statistic. The conversion is chosen from the file's annotation and the table column's type, whichgetFileMetricsnow receives: x1000, /1000 (lower rounded down, upper up), x10^6, or unsigned. INT32 columns that would throw (millistime, uint32) are shown to Iceberg without their annotation so it computes int bounds, then converted. A bound that overflows the table's unit (e.g. year 9999 in nanos) is dropped. If the table column isn't the matching type (e.g. a timestamp file column under along), the bounds stay as Iceberg computes them, as on master. Worth checking: the annotation stripping, the rounding direction and the overflow handling.voidtransform is exempt: it maps everything to null.Part of #40144
Thank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:
addresses #123), if applicable. This will automatically add a link to the pull request in the issue. If you would like the issue to automatically close on merging the pull request, commentfixes #<ISSUE NUMBER>instead.CHANGES.mdwith noteworthy changes.See the Contributor Guide for more tips on how to make review process smoother.
To check the build health, please visit https://github.com/apache/beam/blob/master/.test-infra/BUILD_STATUS.md
GitHub Actions Tests Status (on master branch)
See CI.md for more information about GitHub Actions CI or the workflows README to see a list of phrases to trigger workflows.