Skip to content

Commit 0d66185

Browse files
committed
Validate CDC chunk-size/norm-level properties and strengthen tests
Raise a clear ValueError for invalid content-defined-chunking config (min-chunk-size <= 0, max-chunk-size <= min-chunk-size, negative norm-level) instead of letting PyArrow's opaque internal error surface. Also parametrize the near-duplicate kwargs tests, add coverage for the new validation, and make the write-path test assert use_content_defined_chunking actually reaches pq.ParquetWriter instead of only checking a round-trip that would pass even if the kwarg were silently dropped.
1 parent 1267ad2 commit 0d66185

3 files changed

Lines changed: 100 additions & 47 deletions

File tree

mkdocs/docs/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ Iceberg tables support table properties to configure table behavior.
8585
| `write.parquet.page-size-bytes` | Size in bytes | 1MB | Set a target threshold for the approximate encoded size of data pages within a column chunk |
8686
| `write.parquet.page-row-limit` | Number of rows | 20000 | Set a target threshold for the maximum number of rows within a column chunk |
8787
| `write.parquet.dict-size-bytes` | Size in bytes | 2MB | Set the dictionary page size limit per row group |
88-
| `write.parquet.content-defined-chunking.enabled` | Boolean | False | Enables content-defined chunking (CDC) for the Parquet writer, which produces stable page boundaries across appends. Requires `pyarrow>=21.0.0`. |
88+
| `write.parquet.content-defined-chunking.enabled` | Boolean | False | Enables content-defined chunking (CDC) for the Parquet writer, which produces stable page boundaries across appends. Requires `pyarrow>=21.0.0`, and raises at write time on older versions. |
8989
| `write.parquet.content-defined-chunking.min-chunk-size` | Size in bytes | 256KB | The minimum chunk size used for content-defined chunking |
9090
| `write.parquet.content-defined-chunking.max-chunk-size` | Size in bytes | 1MB | The maximum chunk size used for content-defined chunking |
9191
| `write.parquet.content-defined-chunking.norm-level` | Integer | 0 | The normalization level for content-defined chunking, controlling how tightly chunk sizes cluster around the average |

pyiceberg/io/pyarrow.py

Lines changed: 31 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2959,28 +2959,44 @@ def _get_parquet_writer_kwargs(table_properties: Properties) -> dict[str, Any]:
29592959
),
29602960
}
29612961

2962+
# Unlike the properties above, which PyArrow's writer never supports and are safe to silently
2963+
# drop, CDC is a version-gated feature: silently ignoring it would produce a table that no longer
2964+
# has the content-defined chunk boundaries the user explicitly asked for, so this raises instead.
29622965
if property_as_bool(
29632966
properties=table_properties,
29642967
property_name=TableProperties.PARQUET_CDC_ENABLED,
29652968
default=TableProperties.PARQUET_CDC_ENABLED_DEFAULT,
29662969
):
29672970
_require_pyarrow_version("21.0.0", "Parquet content-defined chunking")
2971+
min_chunk_size = property_as_int(
2972+
properties=table_properties,
2973+
property_name=TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE,
2974+
default=TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
2975+
)
2976+
max_chunk_size = property_as_int(
2977+
properties=table_properties,
2978+
property_name=TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE,
2979+
default=TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
2980+
)
2981+
norm_level = property_as_int(
2982+
properties=table_properties,
2983+
property_name=TableProperties.PARQUET_CDC_NORM_LEVEL,
2984+
default=TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
2985+
)
2986+
if min_chunk_size is not None and min_chunk_size <= 0:
2987+
raise ValueError(f"{TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE} must be greater than 0, got {min_chunk_size}")
2988+
if max_chunk_size is not None and min_chunk_size is not None and max_chunk_size <= min_chunk_size:
2989+
raise ValueError(
2990+
f"{TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE} ({max_chunk_size}) must be greater than "
2991+
f"{TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE} ({min_chunk_size})"
2992+
)
2993+
if norm_level is not None and norm_level < 0:
2994+
raise ValueError(f"{TableProperties.PARQUET_CDC_NORM_LEVEL} must be greater than or equal to 0, got {norm_level}")
2995+
29682996
parquet_writer_kwargs["use_content_defined_chunking"] = {
2969-
"min_chunk_size": property_as_int(
2970-
properties=table_properties,
2971-
property_name=TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE,
2972-
default=TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
2973-
),
2974-
"max_chunk_size": property_as_int(
2975-
properties=table_properties,
2976-
property_name=TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE,
2977-
default=TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
2978-
),
2979-
"norm_level": property_as_int(
2980-
properties=table_properties,
2981-
property_name=TableProperties.PARQUET_CDC_NORM_LEVEL,
2982-
default=TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
2983-
),
2997+
"min_chunk_size": min_chunk_size,
2998+
"max_chunk_size": max_chunk_size,
2999+
"norm_level": norm_level,
29843000
}
29853001

29863002
return parquet_writer_kwargs

tests/io/test_pyarrow.py

Lines changed: 68 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5465,34 +5465,32 @@ def test_dictionary_columns_produces_dict_encoded_output(tmpdir: str) -> None:
54655465
assert result_plain.column("label").to_pylist() == result_dict.column("label").to_pylist()
54665466

54675467

5468-
def test_get_parquet_writer_kwargs_cdc_disabled_by_default() -> None:
5469-
kwargs = _get_parquet_writer_kwargs({})
5470-
assert "use_content_defined_chunking" not in kwargs
5471-
5472-
5473-
def test_get_parquet_writer_kwargs_cdc_enabled_with_defaults() -> None:
5474-
kwargs = _get_parquet_writer_kwargs({TableProperties.PARQUET_CDC_ENABLED: "true"})
5475-
assert kwargs["use_content_defined_chunking"] == {
5476-
"min_chunk_size": TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
5477-
"max_chunk_size": TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
5478-
"norm_level": TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
5479-
}
5480-
5481-
5482-
def test_get_parquet_writer_kwargs_cdc_enabled_with_custom_values() -> None:
5483-
kwargs = _get_parquet_writer_kwargs(
5484-
{
5485-
TableProperties.PARQUET_CDC_ENABLED: "true",
5486-
TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE: "4096",
5487-
TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE: "8192",
5488-
TableProperties.PARQUET_CDC_NORM_LEVEL: "2",
5489-
}
5490-
)
5491-
assert kwargs["use_content_defined_chunking"] == {
5492-
"min_chunk_size": 4096,
5493-
"max_chunk_size": 8192,
5494-
"norm_level": 2,
5495-
}
5468+
@pytest.mark.parametrize(
5469+
"table_properties,expected",
5470+
[
5471+
({}, None),
5472+
(
5473+
{TableProperties.PARQUET_CDC_ENABLED: "true"},
5474+
{
5475+
"min_chunk_size": TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
5476+
"max_chunk_size": TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
5477+
"norm_level": TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
5478+
},
5479+
),
5480+
(
5481+
{
5482+
TableProperties.PARQUET_CDC_ENABLED: "true",
5483+
TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE: "4096",
5484+
TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE: "8192",
5485+
TableProperties.PARQUET_CDC_NORM_LEVEL: "2",
5486+
},
5487+
{"min_chunk_size": 4096, "max_chunk_size": 8192, "norm_level": 2},
5488+
),
5489+
],
5490+
)
5491+
def test_get_parquet_writer_kwargs_cdc(table_properties: dict[str, str], expected: dict[str, int] | None) -> None:
5492+
kwargs = _get_parquet_writer_kwargs(table_properties)
5493+
assert kwargs.get("use_content_defined_chunking") == expected
54965494

54975495

54985496
def test_get_parquet_writer_kwargs_cdc_enabled_unsupported_pyarrow_version(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -5501,8 +5499,40 @@ def test_get_parquet_writer_kwargs_cdc_enabled_unsupported_pyarrow_version(monke
55015499
_get_parquet_writer_kwargs({TableProperties.PARQUET_CDC_ENABLED: "true"})
55025500

55035501

5502+
@pytest.mark.parametrize(
5503+
"table_properties,match",
5504+
[
5505+
(
5506+
{
5507+
TableProperties.PARQUET_CDC_ENABLED: "true",
5508+
TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE: "0",
5509+
},
5510+
"min-chunk-size must be greater than 0",
5511+
),
5512+
(
5513+
{
5514+
TableProperties.PARQUET_CDC_ENABLED: "true",
5515+
TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE: "8192",
5516+
TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE: "4096",
5517+
},
5518+
"max-chunk-size .* must be greater than .*min-chunk-size",
5519+
),
5520+
(
5521+
{
5522+
TableProperties.PARQUET_CDC_ENABLED: "true",
5523+
TableProperties.PARQUET_CDC_NORM_LEVEL: "-1",
5524+
},
5525+
"norm-level must be greater than or equal to 0",
5526+
),
5527+
],
5528+
)
5529+
def test_get_parquet_writer_kwargs_cdc_invalid_properties(table_properties: dict[str, str], match: str) -> None:
5530+
with pytest.raises(ValueError, match=match):
5531+
_get_parquet_writer_kwargs(table_properties)
5532+
5533+
55045534
def test_write_file_with_content_defined_chunking_enabled(tmp_path: Path) -> None:
5505-
"""Writing a table with CDC enabled should succeed and produce a readable Parquet file."""
5535+
"""Writing a table with CDC enabled should forward use_content_defined_chunking to pq.ParquetWriter."""
55065536
from pyiceberg.table import WriteTask
55075537

55085538
table_schema = Schema(NestedField(1, "id", IntegerType(), required=False))
@@ -5524,8 +5554,15 @@ def test_write_file_with_content_defined_chunking_enabled(tmp_path: Path) -> Non
55245554
schema=table_schema,
55255555
)
55265556

5527-
data_files = list(write_file(io=PyArrowFileIO(), table_metadata=table_metadata, tasks=iter([task])))
5528-
assert len(data_files) == 1
5557+
with patch("pyiceberg.io.pyarrow.pq.ParquetWriter", wraps=pq.ParquetWriter) as mock_writer:
5558+
data_files = list(write_file(io=PyArrowFileIO(), table_metadata=table_metadata, tasks=iter([task])))
55295559

5560+
assert mock_writer.call_args.kwargs["use_content_defined_chunking"] == {
5561+
"min_chunk_size": TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
5562+
"max_chunk_size": TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
5563+
"norm_level": TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
5564+
}
5565+
5566+
assert len(data_files) == 1
55305567
written_table = pq.read_table(data_files[0].file_path.replace("file://", ""))
55315568
assert written_table.column("id").to_pylist() == list(range(1000))

0 commit comments

Comments
 (0)