Skip to content

Commit d7f61aa

Browse files
committed
Add Parquet content-defined chunking (CDC) writer support
PyArrow's ParquetWriter has supported content-defined chunking natively since 21.0.0, producing stable page boundaries across appends for content-addressable storage. Wire this through as write.parquet.content-defined-chunking.* table properties, mirroring the property names and defaults already used by iceberg-rust. PyArrow validates the chunk sizes itself and raises a clear error, so pyiceberg doesn't duplicate those checks. Requesting CDC on an older PyArrow raises an ImportError from a shared _require_pyarrow_version helper, which also replaces the existing Azure filesystem guard.
1 parent 58749a3 commit d7f61aa

4 files changed

Lines changed: 143 additions & 9 deletions

File tree

mkdocs/docs/configuration.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ 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`, and raises at write time on older versions. |
89+
| `write.parquet.content-defined-chunking.min-chunk-size` | Size in bytes | 256KiB (262144) | The minimum chunk size used for content-defined chunking |
90+
| `write.parquet.content-defined-chunking.max-chunk-size` | Size in bytes | 1MiB (1048576) | The maximum chunk size used for content-defined chunking |
91+
| `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 |
8892
| `write.metadata.previous-versions-max` | Integer | 100 | The max number of previous version metadata files to keep before deleting after commit. |
8993
| `write.metadata.delete-after-commit.enabled` | Boolean | False | Whether to automatically delete old *tracked* metadata files after each table commit. It will retain a number of the most recent metadata files, which can be set using property `write.metadata.previous-versions-max`. |
9094
| `write.object-storage.enabled` | Boolean | False | Enables the [`ObjectStoreLocationProvider`](configuration.md#object-store-location-provider) that adds a hash component to file paths. |

pyiceberg/io/pyarrow.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,13 @@ def to_input_file(self) -> PyArrowFile:
393393
return self
394394

395395

396+
def _require_pyarrow_version(min_version: str, feature: str) -> None:
397+
from packaging import version
398+
399+
if version.parse(pyarrow.__version__) < version.parse(min_version):
400+
raise ImportError(f"pyarrow version >= {min_version} required for {feature}, but found version {pyarrow.__version__}.")
401+
402+
396403
class PyArrowFileIO(FileIO):
397404
fs_by_scheme: Callable[[str, str | None], FileSystem]
398405

@@ -535,14 +542,7 @@ def _initialize_s3_fs(self, netloc: str | None) -> FileSystem:
535542

536543
def _initialize_azure_fs(self) -> FileSystem:
537544
# https://arrow.apache.org/docs/python/generated/pyarrow.fs.AzureFileSystem.html
538-
from packaging import version
539-
540-
MIN_PYARROW_VERSION_SUPPORTING_AZURE_FS = "20.0.0"
541-
if version.parse(pyarrow.__version__) < version.parse(MIN_PYARROW_VERSION_SUPPORTING_AZURE_FS):
542-
raise ImportError(
543-
f"pyarrow version >= {MIN_PYARROW_VERSION_SUPPORTING_AZURE_FS} required for AzureFileSystem support, "
544-
f"but found version {pyarrow.__version__}."
545-
)
545+
_require_pyarrow_version("20.0.0", "AzureFileSystem support")
546546

547547
from pyarrow.fs import AzureFileSystem
548548

@@ -2939,7 +2939,7 @@ def _get_parquet_writer_kwargs(table_properties: Properties) -> dict[str, Any]:
29392939
if compression_codec == ICEBERG_UNCOMPRESSED_CODEC:
29402940
compression_codec = PYARROW_UNCOMPRESSED_CODEC
29412941

2942-
return {
2942+
parquet_writer_kwargs = {
29432943
"compression": compression_codec,
29442944
"compression_level": compression_level,
29452945
"data_page_size": property_as_int(
@@ -2959,6 +2959,37 @@ 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.
2965+
if property_as_bool(
2966+
properties=table_properties,
2967+
property_name=TableProperties.PARQUET_CDC_ENABLED,
2968+
default=TableProperties.PARQUET_CDC_ENABLED_DEFAULT,
2969+
):
2970+
_require_pyarrow_version("21.0.0", "Parquet content-defined chunking")
2971+
# PyArrow itself validates these values (e.g. max-chunk-size > min-chunk-size) and raises a
2972+
# clear OSError, so there's no need to duplicate that validation here.
2973+
parquet_writer_kwargs["use_content_defined_chunking"] = {
2974+
"min_chunk_size": property_as_int(
2975+
properties=table_properties,
2976+
property_name=TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE,
2977+
default=TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
2978+
),
2979+
"max_chunk_size": property_as_int(
2980+
properties=table_properties,
2981+
property_name=TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE,
2982+
default=TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
2983+
),
2984+
"norm_level": property_as_int(
2985+
properties=table_properties,
2986+
property_name=TableProperties.PARQUET_CDC_NORM_LEVEL,
2987+
default=TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
2988+
),
2989+
}
2990+
2991+
return parquet_writer_kwargs
2992+
29622993

29632994
def _dataframe_to_data_files(
29642995
table_metadata: TableMetadata,

pyiceberg/table/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ class TableProperties:
161161

162162
PARQUET_BLOOM_FILTER_COLUMN_ENABLED_PREFIX = "write.parquet.bloom-filter-enabled.column"
163163

164+
PARQUET_CDC_ENABLED = "write.parquet.content-defined-chunking.enabled"
165+
PARQUET_CDC_ENABLED_DEFAULT = False
166+
167+
PARQUET_CDC_MIN_CHUNK_SIZE = "write.parquet.content-defined-chunking.min-chunk-size"
168+
PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT = 256 * 1024 # 256 KiB
169+
170+
PARQUET_CDC_MAX_CHUNK_SIZE = "write.parquet.content-defined-chunking.max-chunk-size"
171+
PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT = 1024 * 1024 # 1 MiB
172+
173+
PARQUET_CDC_NORM_LEVEL = "write.parquet.content-defined-chunking.norm-level"
174+
PARQUET_CDC_NORM_LEVEL_DEFAULT = 0
175+
164176
WRITE_TARGET_FILE_SIZE_BYTES = "write.target-file-size-bytes"
165177
WRITE_TARGET_FILE_SIZE_BYTES_DEFAULT = 512 * 1024 * 1024 # 512 MB
166178

tests/io/test_pyarrow.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@
7474
_check_pyarrow_schema_compatible,
7575
_ConvertToArrowSchema,
7676
_determine_partitions,
77+
_get_parquet_writer_kwargs,
7778
_primitive_to_physical,
7879
_read_deletes,
7980
_task_to_record_batches,
@@ -5462,3 +5463,89 @@ def test_dictionary_columns_produces_dict_encoded_output(tmpdir: str) -> None:
54625463

54635464
# Values must be identical
54645465
assert result_plain.column("label").to_pylist() == result_dict.column("label").to_pylist()
5466+
5467+
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
5494+
5495+
5496+
def test_get_parquet_writer_kwargs_cdc_enabled_unsupported_pyarrow_version(monkeypatch: pytest.MonkeyPatch) -> None:
5497+
monkeypatch.setattr(pyarrow, "__version__", "17.0.0")
5498+
with pytest.raises(ImportError, match="pyarrow version >= 21.0.0"):
5499+
_get_parquet_writer_kwargs({TableProperties.PARQUET_CDC_ENABLED: "true"})
5500+
5501+
5502+
def test_get_parquet_writer_kwargs_cdc_invalid_chunk_sizes_raises_from_pyarrow() -> None:
5503+
"""PyArrow validates min/max chunk sizes itself; pyiceberg doesn't duplicate that check."""
5504+
kwargs = _get_parquet_writer_kwargs(
5505+
{
5506+
TableProperties.PARQUET_CDC_ENABLED: "true",
5507+
TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE: "8192",
5508+
TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE: "4096",
5509+
}
5510+
)
5511+
table = pa.table({"id": pa.array([1, 2, 3], type=pa.int32())})
5512+
with pytest.raises(pa.ArrowIOError, match="max_chunk_size"):
5513+
with pq.ParquetWriter(pa.BufferOutputStream(), table.schema, **kwargs) as writer:
5514+
writer.write_table(table)
5515+
5516+
5517+
def test_write_file_with_content_defined_chunking_enabled(tmp_path: Path) -> None:
5518+
"""Writing a table with CDC enabled should forward use_content_defined_chunking to pq.ParquetWriter."""
5519+
from pyiceberg.table import WriteTask
5520+
5521+
table_schema = Schema(NestedField(1, "id", IntegerType(), required=False))
5522+
arrow_data = pa.table({"id": pa.array(range(1000), type=pa.int32())})
5523+
5524+
table_metadata = TableMetadataV2(
5525+
location=f"file://{tmp_path}",
5526+
last_column_id=1,
5527+
format_version=2,
5528+
schemas=[table_schema],
5529+
partition_specs=[PartitionSpec()],
5530+
properties={TableProperties.PARQUET_CDC_ENABLED: "true"},
5531+
)
5532+
5533+
task = WriteTask(
5534+
write_uuid=uuid.uuid4(),
5535+
task_id=0,
5536+
record_batches=arrow_data.to_batches(),
5537+
schema=table_schema,
5538+
)
5539+
5540+
with patch("pyiceberg.io.pyarrow.pq.ParquetWriter", wraps=pq.ParquetWriter) as mock_writer:
5541+
data_files = list(write_file(io=PyArrowFileIO(), table_metadata=table_metadata, tasks=iter([task])))
5542+
5543+
assert mock_writer.call_args.kwargs["use_content_defined_chunking"] == {
5544+
"min_chunk_size": TableProperties.PARQUET_CDC_MIN_CHUNK_SIZE_DEFAULT,
5545+
"max_chunk_size": TableProperties.PARQUET_CDC_MAX_CHUNK_SIZE_DEFAULT,
5546+
"norm_level": TableProperties.PARQUET_CDC_NORM_LEVEL_DEFAULT,
5547+
}
5548+
5549+
assert len(data_files) == 1
5550+
written_table = pq.read_table(data_files[0].file_path.replace("file://", ""))
5551+
assert written_table.column("id").to_pylist() == list(range(1000))

0 commit comments

Comments
 (0)