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 d28225662f [python] Reject unsupported aggregation options before 
writing (#10165)
d28225662f is described below

commit d28225662fea4cb546c29ced41e821cd551bbdfc
Author: chaoyang <[email protected]>
AuthorDate: Sat Oct 3 17:13:08 2026 +0800

    [python] Reject unsupported aggregation options before writing (#10165)
---
 .../pypaimon/tests/test_aggregation_e2e.py         | 71 ++++++++++++++++++++--
 .../pypaimon/tests/test_partial_update_e2e.py      | 22 +++----
 paimon-python/pypaimon/write/file_store_write.py   | 16 ++---
 3 files changed, 80 insertions(+), 29 deletions(-)

diff --git a/paimon-python/pypaimon/tests/test_aggregation_e2e.py 
b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
index 2d893e1341..31968d0d78 100644
--- a/paimon-python/pypaimon/tests/test_aggregation_e2e.py
+++ b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
@@ -32,10 +32,12 @@ identifiers) must raise ``NotImplementedError`` at TableRead
 construction rather than silently fall back to a wrong answer.
 """
 
+import glob
 import os
 import shutil
 import tempfile
 import unittest
+from unittest.mock import patch
 
 import pyarrow as pa
 import pytest
@@ -233,12 +235,15 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
         table = self._create_pk_table(
             table_name, extra_options=extra_options
         )
-        rows = [{'id': 1, 'total': 1, 'max_score': 1, 'label': 'a'}]
-        if error_type is ValueError:
-            with self.assertRaises(error_type):
-                self._write(table, rows)
-        else:
-            self._write(table, rows)
+        with self.assertRaises(error_type) as write_error:
+            self._write(table, [
+                {'id': 1, 'total': 10, 'max_score': 1, 'label': 'a'},
+                {'id': 1, 'total': 20, 'max_score': 2, 'label': 'b'},
+            ])
+        self.assertIn(expected_substring, str(write_error.exception))
+        self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+        self.assertEqual(glob.glob(
+            os.path.join(table.table_path, '**', '*.parquet'), 
recursive=True), [])
         rb = table.new_read_builder()
         with self.assertRaises(error_type) as cm:
             rb.new_read()
@@ -247,6 +252,7 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
             self.assertIn('aggregation', msg)
         self.assertIn(expected_substring, msg)
 
+    @pytest.mark.python_write
     @pytest.mark.python_read
     def test_remove_record_on_delete_rejected(self):
         self._create_and_expect_unsupported(
@@ -255,6 +261,7 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
             'aggregation.remove-record-on-delete',
         )
 
+    @pytest.mark.python_write
     @pytest.mark.python_read
     def test_field_ignore_retract_rejected(self):
         self._create_and_expect_unsupported(
@@ -263,6 +270,58 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
             'fields.total.ignore-retract',
         )
 
+    @pytest.mark.python_write
+    def test_unsupported_stream_write_rejected_at_construction(self):
+        table = self._create_pk_table(
+            'agg_stream_reject', field_aggs={'total': 'sum'},
+            extra_options={'aggregation.remove-record-on-delete': 'true'})
+        with self.assertRaisesRegex(
+                NotImplementedError, 'aggregation.remove-record-on-delete'):
+            table.new_stream_write_builder().new_write()
+        self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+        self.assertEqual(glob.glob(
+            os.path.join(table.table_path, '**', '*.parquet'), 
recursive=True), [])
+
+    @pytest.mark.python_write
+    def test_dynamic_bucket_rejected_before_index_creation(self):
+        from pypaimon.write.table_write import BatchTableWrite, 
StreamTableWrite
+
+        for streaming in (False, True):
+            table = self._create_pk_table(
+                'agg_dynamic_reject_{}'.format(streaming), extra_options={
+                    'bucket': '-1',
+                    'aggregation.remove-record-on-delete': 'true',
+                })
+            builder = (table.new_stream_write_builder() if streaming
+                       else table.new_batch_write_builder())
+            writer_class = StreamTableWrite if streaming else BatchTableWrite
+            # A rejected writer must never create the bucket index maintainer,
+            # including callers that construct TableWrite directly.
+            with patch.object(table, 'create_row_key_extractor') as extractor:
+                for create in (builder.new_write, lambda: writer_class(table, 
'test')):
+                    with self.subTest(streaming=streaming, create=create):
+                        with self.assertRaisesRegex(
+                                NotImplementedError, 
'aggregation.remove-record-on-delete'):
+                            create()
+                extractor.assert_not_called()
+            self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
+            self.assertEqual(glob.glob(os.path.join(table.table_path, 'index', 
'*')), [])
+            self.assertEqual(glob.glob(
+                os.path.join(table.table_path, '**', '*.parquet'), 
recursive=True), [])
+
+    @pytest.mark.python_write
+    def test_false_retract_options_remain_writable(self):
+        table = self._create_pk_table(
+            'agg_false_retract', field_aggs={'total': 'sum'}, extra_options={
+                'aggregation.remove-record-on-delete': 'false',
+                'fields.total.ignore-retract': 'false',
+            })
+        self._write(table, [{'id': 1, 'total': 10}])
+        self._write(table, [{'id': 1, 'total': 20}])
+        # This tests write acceptance. Native read support for false-valued
+        # retract options is independent of the write-side guard.
+        self.assertEqual(table.snapshot_manager().get_latest_snapshot().id, 2)
+
     def test_sequence_field_supported(self):
         # Top-level sequence.field is honored by the aggregation engine:
         # aggregators fold in sequence-field order, not file order. Here
diff --git a/paimon-python/pypaimon/tests/test_partial_update_e2e.py 
b/paimon-python/pypaimon/tests/test_partial_update_e2e.py
index b94dcc239f..46d225934a 100644
--- a/paimon-python/pypaimon/tests/test_partial_update_e2e.py
+++ b/paimon-python/pypaimon/tests/test_partial_update_e2e.py
@@ -291,22 +291,14 @@ class PartialUpdateMergeEngineE2ETest(unittest.TestCase):
         )
 
     @pytest.mark.python_write
-    def test_aggregation_engine_write_logs_fallback_warning(self):
-        """The write-side fallback to deduplicate for unsupported engines
-        is silent in terms of return value -- a ``logging.warning`` is
-        the only signal that file contents will not match the table's
-        declared semantics. Important when the same table is read back
-        by a reader that honours the declared engine; the pypaimon
-        read-side raise wouldn't fire there.
-        """
-        table = self._create_pk_table('agg_warning',
-                                      merge_engine='aggregation')
-        with self.assertLogs(
-                'pypaimon.write.file_store_write', level='WARNING') as cm:
+    def test_unsupported_aggregation_write_rejected(self):
+        table = self._create_pk_table(
+            'agg_unsupported', merge_engine='aggregation',
+            extra_options={'aggregation.remove-record-on-delete': 'true'})
+        with self.assertRaisesRegex(
+                NotImplementedError, 'aggregation.remove-record-on-delete'):
             self._write(table, [{'id': 1, 'a': 'x', 'b': None, 'c': None}])
-        combined = '\n'.join(cm.output)
-        self.assertIn('aggregation', combined)
-        self.assertIn('deduplicate', combined)
+        self.assertIsNone(table.snapshot_manager().get_latest_snapshot())
 
     # -- partial-update + out-of-scope option combinations ---------------
     #
diff --git a/paimon-python/pypaimon/write/file_store_write.py 
b/paimon-python/pypaimon/write/file_store_write.py
index 596cd8594d..28e9f8f67d 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -41,11 +41,16 @@ class FileStoreWrite:
 
     def __init__(self, table, commit_user):
         from pypaimon.table.file_store_table import FileStoreTable
+        from pypaimon.common.options.core_options import MergeEngine
         from pypaimon.read.merge_engine_support import 
check_sequence_field_supported
 
         # TableWrite constructs this before the row-key extractor, whose
         # dynamic bucket index must not retain hashes for rejected writes.
         check_sequence_field_supported(table)
+        if table.is_primary_key_table and table.options.merge_engine() == 
MergeEngine.AGGREGATE:
+            from pypaimon.read.merge_engine_support import check_supported
+
+            check_supported(table)
 
         self.table: FileStoreTable = table
         self.data_writers: Dict[Tuple, DataWriter] = {}
@@ -219,14 +224,9 @@ class FileStoreWrite:
         partial-update with no out-of-scope options) cannot drift
         between sides.
 
-        For wholly unsupported engines (``aggregation``) the writer
-        falls back to ``DeduplicateMergeFunction`` so the flushed file
-        still maintains the LSM "PK unique within a file" invariant.
-        The read path's dispatch still raises ``NotImplementedError``,
-        so the user gets an explicit error before they observe
-        wrong-engine data; the fallback only narrows the damage to
-        "file is deduped, not aggregated" rather than the silent
-        multi-row-per-PK corruption that existed pre-PR.
+        Aggregation options are validated with the read-side guard at writer
+        construction. Unsupported configurations must not be committed using
+        fallback merge semantics that discard input values.
 
         Partial-update with out-of-scope options (sequence-group,
         per-field aggregator, ignore-delete, remove-record-on-*) does

Reply via email to