Skip to content

Partition-scoped position deletes are not applied in NaN identity partitions #4279

Open

Description

@hcrosse

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.

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:

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 looks up partition-scoped deletes by (spec_id, Record). Record.__eq__ 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 and the float and double comparators.

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 #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. PositionDeleteWriter.metrics() 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. Any Spark version writes it with write.delete.granularity=partition.

Related:

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
  • 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

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions