This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 4041b51a43 [python] Pass Mosaic options to writer (#8400)
4041b51a43 is described below

commit 4041b51a43cf0f350ef072354a9630d5d7cd8fb6
Author: QuakeWang <[email protected]>
AuthorDate: Wed Jul 1 13:29:04 2026 +0800

    [python] Pass Mosaic options to writer (#8400)
    
    Python Mosaic writes built data files with `mosaic.write_table(data,
    stream)` and did not pass table options into Mosaic `WriterOptions`. As
    a result, Python tables using `file.format=mosaic` ignored configured
    `mosaic.num-buckets`, `mosaic.stats-columns`, `file.block-size`, and
    `file.compression.zstd-level`.
    
    This PR maps the relevant table options into Mosaic writer options and
    passes them through Python data writers and FileIO implementations.
---
 .../pypaimon/common/options/core_options.py        |  30 +++++
 .../pypaimon/filesystem/hdfs_native_file_io.py     |   2 +-
 paimon-python/pypaimon/filesystem/local_file_io.py |   2 +-
 .../pypaimon/filesystem/pyarrow_file_io.py         |   2 +-
 .../pypaimon/tests/mosaic_writer_options_test.py   | 132 +++++++++++++++++++++
 .../pypaimon/write/writer/data_vector_writer.py    |   2 +-
 paimon-python/pypaimon/write/writer/data_writer.py |   8 +-
 .../write/writer/dedicated_format_writer.py        |   2 +-
 .../pypaimon/write/writer/mosaic_writer_options.py |  44 +++++++
 9 files changed, 218 insertions(+), 6 deletions(-)

diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 2dc8049601..56b15ebfe6 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -263,6 +263,23 @@ class CoreOptions:
         .with_description("Define the data block size.")
     )
 
+    MOSAIC_STATS_COLUMNS: ConfigOption[str] = (
+        ConfigOptions.key("mosaic.stats-columns")
+        .string_type()
+        .default_value("")
+        .with_description(
+            "Comma-separated list of column names to collect statistics for. "
+            "Empty means no statistics collection."
+        )
+    )
+
+    MOSAIC_NUM_BUCKETS: ConfigOption[int] = (
+        ConfigOptions.key("mosaic.num-buckets")
+        .int_type()
+        .no_default_value()
+        .with_description("Number of column buckets for parallel IO.")
+    )
+
     METADATA_STATS_MODE: ConfigOption[str] = (
         ConfigOptions.key("metadata.stats-mode")
         .string_type()
@@ -940,6 +957,19 @@ class CoreOptions:
     def file_block_size(self, default=None):
         return self.options.get(CoreOptions.FILE_BLOCK_SIZE, default)
 
+    def mosaic_stats_columns(self, default=None):
+        value = self.options.get(CoreOptions.MOSAIC_STATS_COLUMNS, default)
+        if value is None:
+            return []
+        if isinstance(value, str):
+            return [column.strip() for column in value.split(",") if 
column.strip()]
+        if isinstance(value, (list, set, tuple)):
+            return [str(column).strip() for column in value if 
str(column).strip()]
+        return []
+
+    def mosaic_num_buckets(self, default=None):
+        return self.options.get(CoreOptions.MOSAIC_NUM_BUCKETS, default)
+
     def metadata_stats_enabled(self, default=None):
         return self.options.get(CoreOptions.METADATA_STATS_MODE, default) == 
"full"
 
diff --git a/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py 
b/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
index aa68e3b747..3c3d45ad86 100644
--- a/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
+++ b/paimon-python/pypaimon/filesystem/hdfs_native_file_io.py
@@ -671,7 +671,7 @@ class HdfsNativeFileIO(FileIO):
         try:
             import mosaic
             with self.new_output_stream(path) as output_stream:
-                mosaic.write_table(data, output_stream)
+                mosaic.write_table(data, output_stream, 
options=kwargs.get("options"))
         except Exception as e:
             self.delete_quietly(path)
             raise RuntimeError(f"Failed to write Mosaic file {path}: {e}") 
from e
diff --git a/paimon-python/pypaimon/filesystem/local_file_io.py 
b/paimon-python/pypaimon/filesystem/local_file_io.py
index d3f5f81f4f..c9848705aa 100644
--- a/paimon-python/pypaimon/filesystem/local_file_io.py
+++ b/paimon-python/pypaimon/filesystem/local_file_io.py
@@ -398,7 +398,7 @@ class LocalFileIO(FileIO):
             import mosaic
             os.makedirs(os.path.dirname(path), exist_ok=True)
             with open(path, 'wb') as f:
-                mosaic.write_table(data, f)
+                mosaic.write_table(data, f, options=kwargs.get("options"))
         except Exception as e:
             self.delete_quietly(path)
             raise RuntimeError(f"Failed to write Mosaic file {path}: {e}") 
from e
diff --git a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py 
b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
index 12d3e91b5c..c9a3a6437c 100644
--- a/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
+++ b/paimon-python/pypaimon/filesystem/pyarrow_file_io.py
@@ -646,7 +646,7 @@ class PyArrowFileIO(FileIO):
         try:
             import mosaic
             with self.new_output_stream(path) as output_stream:
-                mosaic.write_table(data, output_stream)
+                mosaic.write_table(data, output_stream, 
options=kwargs.get("options"))
         except Exception as e:
             self.delete_quietly(path)
             raise RuntimeError(f"Failed to write Mosaic file {path}: {e}") 
from e
diff --git a/paimon-python/pypaimon/tests/mosaic_writer_options_test.py 
b/paimon-python/pypaimon/tests/mosaic_writer_options_test.py
new file mode 100644
index 0000000000..0eac7c3849
--- /dev/null
+++ b/paimon-python/pypaimon/tests/mosaic_writer_options_test.py
@@ -0,0 +1,132 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import sys
+import types
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.schema.data_types import AtomicType, DataField
+from pypaimon.write.writer.append_only_data_writer import AppendOnlyDataWriter
+from pypaimon.write.writer.mosaic_writer_options import 
create_mosaic_writer_options
+
+
+class _FakeWriterOptions:
+    def __init__(self, **kwargs):
+        self.kwargs = kwargs
+
+
+class _PathFactory:
+    def __init__(self, bucket_path):
+        self._bucket_path = bucket_path
+
+    def create_external_path_provider(self, partition, bucket):
+        return None
+
+    def bucket_path(self, partition, bucket):
+        return self._bucket_path
+
+
+class _FileIO:
+    def __init__(self):
+        self.mosaic_options = None
+
+    def write_mosaic(self, path, data, **kwargs):
+        self.mosaic_options = kwargs.get("options")
+
+    def get_file_size(self, path):
+        return 1
+
+    def delete_quietly(self, path):
+        pass
+
+
+def test_create_mosaic_writer_options_maps_table_options(monkeypatch):
+    fake_mosaic = types.SimpleNamespace(WriterOptions=_FakeWriterOptions)
+    monkeypatch.setitem(sys.modules, "mosaic", fake_mosaic)
+    options = CoreOptions.from_dict({
+        "file.compression.zstd-level": "5",
+        "file.block-size": "64kb",
+        "mosaic.num-buckets": "8",
+        "mosaic.stats-columns": " id, name ,,",
+    })
+
+    writer_options = create_mosaic_writer_options(options)
+
+    assert writer_options.kwargs == {
+        "zstd_level": 5,
+        "num_buckets": 8,
+        "row_group_max_size": 64 * 1024,
+        "stats_columns": ["id", "name"],
+    }
+
+
+def 
test_create_mosaic_writer_options_rejects_non_zstd_compression(monkeypatch):
+    fake_mosaic = types.SimpleNamespace(WriterOptions=_FakeWriterOptions)
+    monkeypatch.setitem(sys.modules, "mosaic", fake_mosaic)
+    options = CoreOptions.from_dict({"file.compression": "snappy"})
+
+    with pytest.raises(ValueError, match="Mosaic format only supports zstd 
compression"):
+        create_mosaic_writer_options(options)
+
+
+def test_write_mosaic_local_file_io_passes_writer_options(monkeypatch, 
tmp_path):
+    captured = {}
+    fake_mosaic = types.SimpleNamespace(
+        write_table=lambda data, output_stream, options=None: captured.update(
+            data=data, options=options))
+    monkeypatch.setitem(sys.modules, "mosaic", fake_mosaic)
+
+    data = pa.table({"id": pa.array([1], type=pa.int32())})
+    writer_options = object()
+    file_io = LocalFileIO({})
+
+    file_io.write_mosaic(str(tmp_path / "data.mosaic"), data, 
options=writer_options)
+
+    assert captured["data"] is data
+    assert captured["options"] is writer_options
+
+
+def test_data_writer_passes_mosaic_writer_options(monkeypatch, tmp_path):
+    fake_mosaic = types.SimpleNamespace(WriterOptions=_FakeWriterOptions)
+    monkeypatch.setitem(sys.modules, "mosaic", fake_mosaic)
+    options = CoreOptions.from_dict({
+        "file.format": "mosaic",
+        "mosaic.num-buckets": "4",
+        "mosaic.stats-columns": "id",
+    })
+    file_io = _FileIO()
+    table = types.SimpleNamespace(
+        file_io=file_io,
+        options=options,
+        is_primary_key_table=True,
+        table_schema=types.SimpleNamespace(id=0),
+        trimmed_primary_keys=["id"],
+        trimmed_primary_keys_fields=[DataField(0, "id", AtomicType("INT"))],
+        fields=[DataField(0, "id", AtomicType("INT"))],
+        path_factory=lambda: _PathFactory(str(tmp_path)),
+    )
+    writer = AppendOnlyDataWriter(table, (), 0, 0, options)
+
+    writer._write_data_to_file(pa.table({"id": pa.array([1], 
type=pa.int32())}))
+
+    assert isinstance(file_io.mosaic_options, _FakeWriterOptions)
+    assert file_io.mosaic_options.kwargs["num_buckets"] == 4
+    assert file_io.mosaic_options.kwargs["stats_columns"] == ["id"]
diff --git a/paimon-python/pypaimon/write/writer/data_vector_writer.py 
b/paimon-python/pypaimon/write/writer/data_vector_writer.py
index f959a35b45..b474957870 100644
--- a/paimon-python/pypaimon/write/writer/data_vector_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_vector_writer.py
@@ -214,7 +214,7 @@ class DataVectorWriter(DataWriter):
         elif self.file_format == CoreOptions.FILE_FORMAT_VORTEX:
             self.file_io.write_vortex(file_path, data)
         elif self.file_format == CoreOptions.FILE_FORMAT_MOSAIC:
-            self.file_io.write_mosaic(file_path, data)
+            self.file_io.write_mosaic(file_path, data, 
options=self.mosaic_writer_options)
         elif self.file_format == CoreOptions.FILE_FORMAT_ROW:
             self.file_io.write_row(file_path, data, zstd_level=self.zstd_level)
         else:
diff --git a/paimon-python/pypaimon/write/writer/data_writer.py 
b/paimon-python/pypaimon/write/writer/data_writer.py
index c32bb6968b..221f0f0498 100644
--- a/paimon-python/pypaimon/write/writer/data_writer.py
+++ b/paimon-python/pypaimon/write/writer/data_writer.py
@@ -29,6 +29,7 @@ from pypaimon.manifest.schema.simple_stats import SimpleStats
 from pypaimon.schema.data_types import PyarrowFieldParser
 from pypaimon.table.bucket_mode import BucketMode
 from pypaimon.table.row.generic_row import GenericRow
+from pypaimon.write.writer.mosaic_writer_options import 
create_mosaic_writer_options
 
 
 class DataWriter(ABC):
@@ -60,6 +61,11 @@ class DataWriter(ABC):
         self.file_format = self.options.file_format(default_format)
         self.compression = self.options.file_compression()
         self.zstd_level = self.options.file_compression_zstd_level()
+        self.mosaic_writer_options = (
+            create_mosaic_writer_options(self.options)
+            if self.file_format == CoreOptions.FILE_FORMAT_MOSAIC
+            else None
+        )
         self.sequence_generator = SequenceGenerator(max_seq_number)
 
         self.pending_data: Optional[pa.Table] = None
@@ -222,7 +228,7 @@ class DataWriter(ABC):
             elif self.file_format == CoreOptions.FILE_FORMAT_VORTEX:
                 self.file_io.write_vortex(file_path, data)
             elif self.file_format == CoreOptions.FILE_FORMAT_MOSAIC:
-                self.file_io.write_mosaic(file_path, data)
+                self.file_io.write_mosaic(file_path, data, 
options=self.mosaic_writer_options)
             elif self.file_format == CoreOptions.FILE_FORMAT_ROW:
                 self.file_io.write_row(file_path, data, 
zstd_level=self.zstd_level)
             else:
diff --git a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py 
b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
index 0ec7439359..4dc4fca2a5 100644
--- a/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
+++ b/paimon-python/pypaimon/write/writer/dedicated_format_writer.py
@@ -396,7 +396,7 @@ class DedicatedFormatWriter(DataWriter):
         elif self.file_format == CoreOptions.FILE_FORMAT_VORTEX:
             self.file_io.write_vortex(file_path, data)
         elif self.file_format == CoreOptions.FILE_FORMAT_MOSAIC:
-            self.file_io.write_mosaic(file_path, data)
+            self.file_io.write_mosaic(file_path, data, 
options=self.mosaic_writer_options)
         elif self.file_format == CoreOptions.FILE_FORMAT_ROW:
             self.file_io.write_row(file_path, data, zstd_level=self.zstd_level)
         else:
diff --git a/paimon-python/pypaimon/write/writer/mosaic_writer_options.py 
b/paimon-python/pypaimon/write/writer/mosaic_writer_options.py
new file mode 100644
index 0000000000..974c0eb66f
--- /dev/null
+++ b/paimon-python/pypaimon/write/writer/mosaic_writer_options.py
@@ -0,0 +1,44 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+from pypaimon.common.options.core_options import CoreOptions
+
+
+def create_mosaic_writer_options(options: CoreOptions):
+    import mosaic
+
+    compression = options.file_compression()
+    if compression is not None and compression.lower() != "zstd":
+        raise ValueError(f"Mosaic format only supports zstd compression, but 
got: {compression}")
+
+    kwargs = {
+        "zstd_level": options.file_compression_zstd_level(),
+    }
+
+    num_buckets = options.mosaic_num_buckets()
+    if num_buckets is not None:
+        kwargs["num_buckets"] = num_buckets
+
+    block_size = options.file_block_size()
+    if block_size is not None:
+        kwargs["row_group_max_size"] = block_size.get_bytes()
+
+    stats_columns = options.mosaic_stats_columns()
+    if stats_columns:
+        kwargs["stats_columns"] = stats_columns
+
+    return mosaic.WriterOptions(**kwargs)

Reply via email to