Repository navigation
Conversation
…lumn After a partition field is removed and its source column is dropped, the old spec's partition field has the unknown type. Rewriting its manifest wrapped the Avro "null" type in another null union, which is invalid Avro and made the table unreadable. Write unknown fields as a plain "null", like Java's AvroSchemaUtil.toOption. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
772b55d to
a0c2e52
Compare
|
Thanks @hzmsh for fixing this. The underlying problem is that we should not write So we should skip over it when writing, and read it as null. |
|
Hi @Fokko, thank you very much for the review and for pointing me to the spec. That makes sense, and I'm happy to change the PR to skip the field when writing and read it back as null. For the "read it back as null" part, I noticed that Exampledata = pa.Table.from_pydict({"id": [1, 2, 3], "category": ["a", "b", "a"], "region": ["x", "y", "y"]})
tbl = catalog.create_table("default.t", schema=data.schema)
with tbl.update_spec() as u:
u.add_identity("category")
u.add_identity("region")
tbl.append(data)
with tbl.update_spec() as u:
u.remove_field("category")
with tbl.update_schema() as u:
u.delete_column("category")
tbl.delete("id == 1")
tbl.scan().to_arrow() # works either way
tbl.scan(row_filter="region == 'y'").to_arrow() # with the field skipped: IndexError in the partition evaluatorSo I'll also update |
…-partition-source
…as null After a partition field is removed and its source column is dropped, the old spec's partition field has the unknown type. Rewriting its manifest wrapped the Avro "null" type in another null union, which is invalid Avro and made the table unreadable. The spec allows unknown to be omitted from Avro. Leave unknown fields out of the manifest file schema, and when reading a manifest, add any partition field from the header's partition-spec that is missing in the file back as an optional field, so it reads as null and the partition fields after it keep their positions. This replaces the earlier change that wrote unknown as a plain "null". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Hi @Fokko, I updated the PR as you suggested: |

Rationale for this change
If you stop partitioning by a column and then drop that column, the next
delete()breaks the table. The commit succeeds, but after that neither PyIceberg nor Spark can read the table.What goes wrong
categoryfield, but the column is gone, so its type becomesunknown.unknownmaps to Avro"null", so the optional field becomes the union["null", "null"], which Avro does not allow. Reading that manifest back fails:IndexErrorin PyIceberg,Duplicate in union:nullin Spark.When it started
to_bytesforUnknownType#3899 (including the 0.12.0 release): step 3 raises an error. The delete fails, but the table is untouched and still readable.to_bytesforUnknownType#3899 (merged tomain, not released yet): step 3 succeeds but writes a broken manifest, so the whole table becomes unreadable.Fix
The spec says
unknownis not stored in data files, and its Avro mapping is "nullor omitted". This PR omits it:ManifestWriter._with_partition):unknownpartition fields are left out of the manifest's Avro schema. The in-memory partition record keeps all fields, and the Avro writer already matches file fields to record positions by field ID, so nothing else changes on the write path.fetch_manifest_entry): today the partition tuple is read with only the fields in the file. Ifcategoryis omitted from a(category, region)spec, the tuple comes back asRecord('y')instead ofRecord(None, 'y'), so readingregionat index 1 fails withIndexError(or reads a later field's value if more fields follow). The reader now takes the field list from thepartition-specin the manifest header and adds any omitted field back as an optional field, which reads asNone. Manifests without omitted fields are read exactly as before. To do this,AvroFilenow also accepts a function that builds the read schema from the file header.specsById) into its manifest reader. Reading the spec from the header is its fallback, which Java marks as deprecated (ManifestReader.java#L156-L164).fetch_manifest_entryhas no spec argument and has 12 call sites, so this PR reads the header and leaves the signature unchanged. Passing specs in could be a follow-up if preferred.Reports from users
Are these changes tested?
Yes.
mainand pass here, and 1 is a regression test that already passes onmain):tests/catalog/test_catalog_behaviors.py::test_delete_after_dropping_old_partition_source_column: the example above (v1 and v2, every catalog fixture), and the omitted field reads back asNone.tests/catalog/test_catalog_behaviors.py::test_filter_after_dropping_earlier_partition_source_column: partitioned bycategoryandregion,categorydropped. Filtering onregionreturns the right rows after the first delete, and again after a second delete that reads and rewrites the same manifest.tests/utils/test_manifest.py::test_write_manifest_skips_unknown_partition_field: theunknownfield is not in the written Avro schema and reads back asNone, with the next field in place.tests/utils/test_manifest.py::test_read_manifest_with_unknown_partition_field_written_as_null: a manifest that writes theunknownfield as Avro"null"(as Java does) still reads correctly.-m unit, with the Cython decoder built) on Linux: 4500 passed vs 4486 onmain; the difference is the 14 new cases. 12 other failures are the same on both (optional packages I don't have, for Kerberos and Azure).rayordaft, which I don't have installed, and fail the same way onmain.[2, 3]forregion = 'y'. After a SparkDELETEofid = 2(Java writes the field as Avro"null"), PyIceberg returns[3]. Spark also reads[2]for the single-field example above (v1 and v2).make lintpasses (ruff, ruff format, mypy, codespell).main(within 3%, best of 5).Are there any user-facing changes?
Yes, a bug fix.
delete()andoverwrite()after dropping an old partition column now write readable manifests.overwrite()in this case still fails earlier withCould not find field with id: 2(the droppedcategorycolumn), with or without this change.tbl.inspect.entries()andtbl.inspect.partitions()fail with the same error after dropping an old partition column, also onmainand with or without a delete. That's a separate issue.["null", "null"]by an unreleasedmainbuild are not repaired.new_writer. If either merges first, I'll addskip_unknown=Truethere in this PR; if this PR merges first, they need the same one-line change.Since #3899 isn't released yet, could this go into the 0.13.0 milestone?
Written with AI assistance (Claude Code, Claude Opus 5.5). The Java references were read from
apache/icebergmain, and the tests above were run on this branch.