hcrosse opened a new issue, #4279:
URL: https://github.com/apache/iceberg-python/issues/4279

   ### Apache Iceberg version
   
   0.12.0 (latest release)
   
   ### Please describe the bug 🐞
   
   Scans can return deleted rows with no error on tables written by Spark or 
Java. This happens in a NaN identity partition when a position delete file 
covers more than one data file.
   
   I reproduced the scan failure on 0.12.0 and main at 
`e3564a54c10960307147377c578680276865ed18`, with Python 3.12 and PyArrow 25.0.1 
on Linux/amd64. The same script returns the correct rows on 0.10.0. The bug 
affects 0.11.0 and later.
   
   The reproduction uses synthetic data in a v2 table identity-partitioned on a 
`double` column. Each partition, `1.0` and NaN, has two data files. One 
position delete file per partition deletes position 0 from both data files, 
without `file_path` bounds. Because PyIceberg cannot write delete files, the 
script writes the Parquet files with PyArrow and commits them through 
`fast_append()` and a small delete-manifest writer, in the shape that Spark or 
Java would produce.
   
   Install `pyiceberg[pyarrow,sql-sqlite]==0.12.0` and `pyarrow==25.0.1`. The 
script runs unchanged on main and 0.10.0.
   
   ```python
   import os
   import tempfile
   
   import pyarrow as pa
   import pyarrow.parquet as pq
   
   from pyiceberg.catalog.sql import SqlCatalog
   from pyiceberg.manifest import DataFile, DataFileContent, FileFormat, 
ManifestContent, ManifestWriterV2
   from pyiceberg.partitioning import PartitionField, PartitionSpec
   from pyiceberg.schema import Schema
   from pyiceberg.transforms import IdentityTransform
   from pyiceberg.typedef import Record
   from pyiceberg.types import DoubleType, IntegerType, NestedField
   
   a, b = Record(float("nan")), Record(float("nan"))
   print(f"Record(nan) == Record(nan): {a == b}; hashes equal: {hash(a) == 
hash(b)}")
   
   warehouse = tempfile.mkdtemp()
   catalog = SqlCatalog("default", uri=f"sqlite:///{warehouse}/catalog.db", 
warehouse=f"file://{warehouse}")
   catalog.create_namespace("default")
   schema = Schema(NestedField(1, "id", IntegerType()), NestedField(2, "value", 
DoubleType()))
   spec = PartitionSpec(PartitionField(source_id=2, field_id=1000, 
transform=IdentityTransform(), name="value"))
   tbl = catalog.create_table("default.t", schema=schema, partition_spec=spec, 
properties={"format-version": "2"})
   
   pos_delete_schema = pa.schema(
       [
           pa.field("file_path", pa.string(), nullable=False, 
metadata={"PARQUET:field_id": "2147483546"}),
           pa.field("pos", pa.int64(), nullable=False, 
metadata={"PARQUET:field_id": "2147483545"}),
       ]
   )
   
   
   def write_file(name: str, rows: pa.Table, value: float, content: 
DataFileContent) -> DataFile:
       path = f"{warehouse}/{name}.parquet"
       pq.write_table(rows, path)
       return DataFile.from_args(
           content=content,
           file_path=path,
           file_format=FileFormat.PARQUET,
           partition=Record(value),
           record_count=rows.num_rows,
           file_size_in_bytes=os.path.getsize(path),
       )
   
   
   class DeleteManifestWriter(ManifestWriterV2):
       def content(self) -> ManifestContent:
           return ManifestContent.DELETES
   
       @property
       def _meta(self) -> dict[str, str]:  # private: the manifest header names 
its content
           return {**super()._meta, "content": "deletes"}
   
   
   # Write files the way Spark or Java would: PyIceberg 0.12 cannot append to a 
NaN partition, and PyIceberg
   # cannot write delete files. Each partition gets two data files and one 
position delete file that removes
   # row 0 of both. A delete file covering two data files has no file_path 
bounds, so it is partition-scoped.
   data_files, delete_files = [], []
   for value, ids in [(1.0, [1, 2, 3, 4]), (float("nan"), [5, 6, 7, 8])]:
       for pair in (ids[:2], ids[2:]):
           rows = pa.Table.from_pydict({"id": pair, "value": [value] * 2}, 
schema=schema.as_arrow())
           data_files.append(write_file(f"data-{pair[0]}", rows, value, 
DataFileContent.DATA))
       deletes = pa.Table.from_pydict({"file_path": [f.file_path for f in 
data_files[-2:]], "pos": [0, 0]}, schema=pos_delete_schema)
       delete_files.append(write_file(f"deletes-{ids[0]}", deletes, value, 
DataFileContent.POSITION_DELETES))
   
   with tbl.transaction() as tx, tx.update_snapshot().fast_append() as append:
       for data_file in data_files:
           append.append_data_file(data_file)
   
   with tbl.transaction() as tx, tx.update_snapshot().overwrite() as overwrite:
       # private: PyIceberg has no delete-manifest writer, so swap one into the 
snapshot producer
       overwrite.new_manifest_writer = lambda spec: DeleteManifestWriter(
           spec, tbl.schema(), overwrite.new_manifest_output(), 
overwrite._snapshot_id, "deflate"
       )
       for delete_file in delete_files:
           overwrite.append_data_file(delete_file)
   
   print("expected ids:", [2, 4, 6, 8])
   print("actual ids:  ", sorted(tbl.scan().to_arrow()["id"].to_pylist()))
   ```
   
   Output on main and 0.12.0:
   
   ```text
   Record(nan) == Record(nan): False; hashes equal: True
   expected ids: [2, 4, 6, 8]
   actual ids:   [2, 4, 5, 6, 7, 8]
   ```
   
   The `1.0` partition is correct in every tested version. Version 0.10.0 
returns the correct rows in both partitions, even though `Record` equality 
already fails for NaN there. I also checked per-file position deletes with 
equal `file_path` bounds separately on main. They work in both partitions, but 
that control is not included in this script.
   
   
[`DeleteFileIndex`](https://github.com/apache/iceberg-python/blob/e3564a54c10960307147377c578680276865ed18/pyiceberg/table/delete_file_index.py#L115-L137)
 looks up partition-scoped deletes by `(spec_id, Record)`. 
[`Record.__eq__`](https://github.com/apache/iceberg-python/blob/e3564a54c10960307147377c578680276865ed18/pyiceberg/typedef.py#L198-L200)
 compares lists, so separately decoded NaN partition values compare unequal 
even though their hashes match. A narrow fix would compare NaN partition values 
as equal, as Java does through 
[`StructLikeWrapper`](https://github.com/apache/iceberg/blob/5a2ccf5ba830838ecf182bccb516d5ee48f8b8ed/core/src/main/java/org/apache/iceberg/util/StructLikeWrapper.java#L74-L104)
 and the [float and double 
comparators](https://github.com/apache/iceberg/blob/5a2ccf5ba830838ecf182bccb516d5ee48f8b8ed/api/src/main/java/org/apache/iceberg/types/Comparators.java#L35-L41).
   
   The same partition lookup also misses in commit validation, in 
`_validate_data_files_exist` and `_validate_no_new_deletes_for_data_files`. 
With the fix for https://github.com/apache/iceberg-python/issues/4277 applied, 
one writer can replace a data file with a copy while another writer adds a 
partition-scoped delete for a row in it. The replacement commits and brings the 
deleted row back for good.
   
   Today, main fails earlier with `ValueError: Cannot create expression literal 
from NaN`, before reaching those checks. A separate required-files check still 
rejects replacement of a concurrently removed data file.
   
   Java's [default delete granularity is 
partition](https://github.com/apache/iceberg/blob/5a2ccf5ba830838ecf182bccb516d5ee48f8b8ed/core/src/main/java/org/apache/iceberg/TableProperties.java#L429-L430).
 
[`PositionDeleteWriter.metrics()`](https://github.com/apache/iceberg/blob/5a2ccf5ba830838ecf182bccb516d5ee48f8b8ed/core/src/main/java/org/apache/iceberg/deletes/PositionDeleteWriter.java#L124-L131)
 removes the path bounds when a delete file covers multiple data files. Spark 
writes this shape by default with Iceberg releases before 1.8.0 and with the 
Spark 3.4 and earlier runtimes. Spark 3.5 and later switched to file 
granularity in [Iceberg 
1.8.0](https://github.com/apache/iceberg/commit/c0d6d42a55a7d33d7d38f73d00e46e86b57d934e).
 Any Spark version writes it with `write.delete.granularity=partition`.
   
   Related:
   
   - https://github.com/apache/iceberg-python/pull/2918 introduced the 
partition-keyed index, first released in 0.11.0.
   - https://github.com/apache/iceberg-python/pull/3285 is open and would key 
equality deletes by the same partition `Record`.
   - https://github.com/apache/iceberg-python/issues/4277 blocks NaN-partition 
rewrites on main, so its fix needs to account for the validation failure 
described here.
   
   I have not run Spark or Java, or tested 0.11.x directly. Java's NaN 
comparison is based on source inspection.
   
   ### Willingness to contribute
   
   - [ ] I can contribute a fix for this bug independently
   - [x] I would be willing to contribute a fix for this bug with guidance from 
the Iceberg community
   - [ ] I cannot contribute a fix for this bug at this time
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to