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 66e2530a73 [python] Honor sequence.field in primary-key writes (#10166)
66e2530a73 is described below

commit 66e2530a73b0c8185460c8ebb5b216fac2b47fed
Author: chaoyang <[email protected]>
AuthorDate: Fri Sep 25 21:29:50 2026 +0800

    [python] Honor sequence.field in primary-key writes (#10166)
---
 .../test/java/org/apache/paimon/JavaPyE2ETest.java |  67 ++++++
 paimon-python/dev/run_mixed_tests.sh               |   4 +-
 .../pypaimon/read/merge_engine_support.py          |  25 ++-
 .../pypaimon/read/reader/sort_merge_reader.py      |  24 +-
 paimon-python/pypaimon/read/table_read.py          |   8 +
 .../pypaimon/tests/e2e/java_py_read_write_test.py  |  49 ++++
 paimon-python/pypaimon/tests/native_read_test.py   |  14 ++
 paimon-python/pypaimon/tests/native_write_test.py  |  42 +++-
 .../pypaimon/tests/test_aggregation_e2e.py         |   8 +-
 .../pypaimon/tests/test_sequence_field_read.py     |  29 ++-
 .../pypaimon/tests/test_sequence_field_write.py    | 249 +++++++++++++++++++++
 .../pypaimon/tests/test_write_merge_buffer.py      |   3 +
 paimon-python/pypaimon/write/file_store_write.py   |   5 +
 paimon-python/pypaimon/write/native_write.py       |   7 +
 paimon-python/pypaimon/write/write_builder.py      |   5 +
 .../pypaimon/write/writer/key_value_data_writer.py |  39 +++-
 16 files changed, 542 insertions(+), 36 deletions(-)

diff --git a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java 
b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
index 8a59c43326..e48bf52b77 100644
--- a/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
+++ b/paimon-core/src/test/java/org/apache/paimon/JavaPyE2ETest.java
@@ -578,6 +578,73 @@ public class JavaPyE2ETest {
         }
     }
 
+    @Test
+    @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
+    public void testReadPythonFloatingSequence() throws Exception {
+        for (String type : Arrays.asList("float", "double")) {
+            for (String order : Arrays.asList("ascending", "descending")) {
+                for (String grouping : Arrays.asList("batch", "commits")) {
+                    FileStoreTable table =
+                            (FileStoreTable)
+                                    catalog.getTable(
+                                            identifier(
+                                                    "floating_sequence_"
+                                                            + type
+                                                            + "_"
+                                                            + order
+                                                            + "_"
+                                                            + grouping));
+                    List<String> result =
+                            getResult(
+                                    table.newRead(),
+                                    table.newScan().plan().splits(),
+                                    row -> {
+                                        String sequence =
+                                                row.isNullAt(1)
+                                                        ? "null"
+                                                        : "float".equals(type)
+                                                                ? 
Float.toString(row.getFloat(1))
+                                                                : 
Double.toString(row.getDouble(1));
+                                        return row.getInt(0)
+                                                + ":"
+                                                + row.getString(2)
+                                                + ":"
+                                                + sequence;
+                                    });
+                    List<String> expected =
+                            new ArrayList<>(
+                                    Arrays.asList(
+                                            "5:nan:NaN",
+                                            "6:nan:NaN",
+                                            "8:nan-last:NaN",
+                                            "10:null-last:null"));
+                    if ("ascending".equals(order)) {
+                        expected.addAll(
+                                Arrays.asList(
+                                        "1:nan:NaN",
+                                        "2:nan:NaN",
+                                        "3:positive-zero:0.0",
+                                        "4:positive-zero:0.0",
+                                        "7:nan:NaN",
+                                        "9:finite:1.0"));
+                    } else {
+                        expected.addAll(
+                                Arrays.asList(
+                                        "1:finite:1.0",
+                                        "2:finite:1.0",
+                                        "3:negative-zero:-0.0",
+                                        "4:negative-zero:-0.0",
+                                        "7:infinity:Infinity",
+                                        "9:negative-infinity:-Infinity"));
+                    }
+                    assertThat(result)
+                            .as("%s sequence, %s, Python %s", type, order, 
grouping)
+                            .containsExactlyInAnyOrderElementsOf(expected);
+                }
+            }
+        }
+    }
+
     @Test
     @EnabledIfSystemProperty(named = "run.e2e.tests", matches = "true")
     public void testReadPythonDynamicBucketHashIndex() throws Exception {
diff --git a/paimon-python/dev/run_mixed_tests.sh 
b/paimon-python/dev/run_mixed_tests.sh
index 319267b663..fc84202a0d 100755
--- a/paimon-python/dev/run_mixed_tests.sh
+++ b/paimon-python/dev/run_mixed_tests.sh
@@ -244,7 +244,7 @@ run_python_write_test() {
 
     # Run the parameterized Python test method for writing data (pk table, 
includes bucket num assertion)
     echo "Running Python test for JavaPyReadWriteTest 
(test_py_write_read_pk_table)..."
-    if python -m pytest java_py_read_write_test.py::JavaPyReadWriteTest -k 
"test_py_write_read_pk_table or test_py_write_dynamic_bucket_hash_index" -v; 
then
+    if python -m pytest java_py_read_write_test.py::JavaPyReadWriteTest -k 
"test_py_write_read_pk_table or test_py_write_dynamic_bucket_hash_index or 
test_py_write_floating_sequence" -v; then
         echo -e "${GREEN}✓ Python write test completed successfully${NC}"
         return 0
     else
@@ -263,7 +263,7 @@ run_java_read_test() {
     echo "Running Maven test for JavaPyE2ETest.testReadPkTable (Java Read 
Parquet/Orc/Avro)..."
     echo "Note: Maven may download dependencies on first run, this may take a 
while..."
     local parquet_result=0
-    if mvn test 
-Dtest=org.apache.paimon.JavaPyE2ETest#testReadPkTable+testReadPythonDynamicBucketHashIndex
 -pl paimon-core -Drun.e2e.tests=true -Dpython.version="$PYTHON_VERSION"; then
+    if mvn test 
-Dtest=org.apache.paimon.JavaPyE2ETest#testReadPkTable+testReadPythonDynamicBucketHashIndex+testReadPythonFloatingSequence
 -pl paimon-core -Drun.e2e.tests=true -Dpython.version="$PYTHON_VERSION"; then
         echo -e "${GREEN}✓ Java read Parquet/Orc/Avro test completed 
successfully${NC}"
     else
         echo -e "${RED}✗ Java read Parquet/Orc/Avro test failed${NC}"
diff --git a/paimon-python/pypaimon/read/merge_engine_support.py 
b/paimon-python/pypaimon/read/merge_engine_support.py
index a7d8337854..51025a0df5 100644
--- a/paimon-python/pypaimon/read/merge_engine_support.py
+++ b/paimon-python/pypaimon/read/merge_engine_support.py
@@ -156,20 +156,14 @@ def check_sequence_field_valid(table) -> None:
         )
 
 
-def check_supported(table) -> None:
-    """Raise ``NotImplementedError`` if the table's merge-engine
-    configuration is outside what pypaimon's read path implements, or
-    ``ValueError`` if it is an outright-invalid configuration that Java
-    rejects at schema validation.
-
-    Non-PK tables are always fine (no merge function involved).
-    """
+def check_sequence_field_supported(table) -> None:
+    """Validate sequence configuration and types before reading or writing."""
     if not table.is_primary_key_table:
         return
     # ``sequence.field`` validity is engine-independent in Java
     # (SchemaValidation.validateSequenceField). pypaimon has no
     # schema-creation validation, so enforce the same invariants here on
-    # the read path, before per-engine dispatch.
+    # both paths, before per-engine dispatch.
     check_sequence_field_valid(table)
     # ``sequence.field`` validity (above) is Java-aligned and engine
     # independent. Some field *types* are valid in Java but unimplemented in
@@ -186,6 +180,19 @@ def check_supported(table) -> None:
             "handled by Java via RecordComparator) and VARIANT are not "
             "supported. Open an issue to track support.".format(
                 ", ".join(sorted(unsupported_seq))))
+
+
+def check_supported(table) -> None:
+    """Raise ``NotImplementedError`` if the table's merge-engine
+    configuration is outside what pypaimon's read path implements, or
+    ``ValueError`` if it is an outright-invalid configuration that Java
+    rejects at schema validation.
+
+    Non-PK tables are always fine (no merge function involved).
+    """
+    if not table.is_primary_key_table:
+        return
+    check_sequence_field_supported(table)
     engine = table.options.merge_engine()
     if engine == MergeEngine.DEDUPLICATE:
         return
diff --git a/paimon-python/pypaimon/read/reader/sort_merge_reader.py 
b/paimon-python/pypaimon/read/reader/sort_merge_reader.py
index b1965b2795..0a1479feff 100644
--- a/paimon-python/pypaimon/read/reader/sort_merge_reader.py
+++ b/paimon-python/pypaimon/read/reader/sort_merge_reader.py
@@ -16,6 +16,7 @@
 # under the License.
 
 import heapq
+import math
 from typing import Any, Callable, List, Optional
 
 from pypaimon.read.reader.deduplicate_merge_function import \
@@ -211,7 +212,8 @@ def is_comparable_seq_field(field: DataField) -> bool:
 def _row_field_comparator(
         fields: List[DataField],
         indices: List[int],
-        ascending: bool = True) -> Callable[[Any, Any], int]:
+        ascending: bool = True,
+        floating_sequence: bool = False) -> Callable[[Any, Any], int]:
     """Build a comparator over two rows on the given ``indices`` (positions
     in ``fields`` / the row's ``get_field``), compared left-to-right.
 
@@ -224,8 +226,13 @@ def _row_field_comparator(
     ``nullIsLast=false`` (see ``CodeGeneratorImpl#getSortSpec``), where
     descending order flips only the non-null value comparison and leaves
     nulls sorting first.
+
+    ``floating_sequence`` enables Java's NaN and signed-zero ordering for
+    sequence fields, independently of primary-key equality.
     """
     comparable_flags = [_base_type_name(fields[idx]) in _COMPARABLE_TYPE_NAMES 
for idx in indices]
+    floating_flags = [floating_sequence and _base_type_name(fields[idx]) in 
('FLOAT', 'DOUBLE')
+                      for idx in indices]
     sign = 1 if ascending else -1
 
     def comparator(row1: InternalRow, row2: InternalRow) -> int:
@@ -249,6 +256,19 @@ def _row_field_comparator(
             if not comparable_flags[pos]:
                 raise ValueError(f"Unsupported {fields[idx].type} comparison")
 
+            if floating_flags[pos]:
+                # Java Float/Double.compare: all NaNs tie above +inf, and
+                # -0.0 precedes +0.0. Primary-key equality is unchanged.
+                if math.isnan(val1):
+                    if not math.isnan(val2):
+                        return sign
+                    continue
+                if math.isnan(val2):
+                    return -sign
+                if val1 == val2 == 0.0:
+                    val1 = math.copysign(1.0, val1)
+                    val2 = math.copysign(1.0, val2)
+
             if val1 < val2:
                 return -sign
             elif val1 > val2:
@@ -315,4 +335,4 @@ def builtin_seq_comparator(
                 f"are not supported -- open an issue to track support.")
         indices.append(idx)
 
-    return _row_field_comparator(value_fields, indices, ascending)
+    return _row_field_comparator(value_fields, indices, ascending, 
floating_sequence=True)
diff --git a/paimon-python/pypaimon/read/table_read.py 
b/paimon-python/pypaimon/read/table_read.py
index d8fbf2ea52..30b552047e 100644
--- a/paimon-python/pypaimon/read/table_read.py
+++ b/paimon-python/pypaimon/read/table_read.py
@@ -435,6 +435,14 @@ class TableRead:
             return None
         if not splits:
             return []
+        sequence_fields = self.table.options.sequence_field()
+        if self.table.is_primary_key_table and sequence_fields:
+            sequence_schema = PyarrowFieldParser.from_paimon_schema(
+                [self.table.field_dict[name] for name in sequence_fields])
+            # Native merge cannot extract floating sequence values: they
+            # would silently become missing sequence values.
+            if any(pyarrow.types.is_floating(field.type) for field in 
sequence_schema):
+                return None
         if not self._native_blob_view_supported():
             return None
         if (self._deferred_blob_limit_may_prune(splits)
diff --git a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py 
b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
index d850206277..fa6ff03103 100644
--- a/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
+++ b/paimon-python/pypaimon/tests/e2e/java_py_read_write_test.py
@@ -142,6 +142,55 @@ class JavaPyReadWriteTest(unittest.TestCase):
         writer.close()
         commit.close()
 
+    @parameterized.expand([
+        (type_name, order, grouping)
+        for type_name in ('float', 'double')
+        for order in ('ascending', 'descending')
+        for grouping in ('batch', 'commits')
+    ])
+    def test_py_write_floating_sequence(self, type_name, order, grouping):
+        table_name = 'default.floating_sequence_{}_{}_{}'.format(type_name, 
order, grouping)
+        self.catalog.drop_table(table_name, True)
+        arrow_schema = pa.schema([
+            pa.field('id', pa.int32(), nullable=False),
+            ('seq', pa.float32() if type_name == 'float' else pa.float64()),
+            ('val', pa.string()),
+        ])
+        self.catalog.create_table(table_name, Schema.from_pyarrow_schema(
+            arrow_schema, primary_keys=['id'], options={
+                'bucket': '1', 'file.format': 'parquet',
+                'sequence.field': 'seq', 'sequence.field.sort-order': order,
+                'write.native.enabled': 'false', 'commit.native.enabled': 
'false',
+            }), False)
+        table = self.catalog.get_table(table_name)
+        # Java verifies both the winning payload and the stored sequence value.
+        # The two versions are either folded together or merged by Java on 
read.
+        versions = [
+            [(1, 1.0, 'finite'), (2, float('nan'), 'nan'),
+             (3, 0.0, 'positive-zero'), (4, -0.0, 'negative-zero'),
+             (5, None, 'null'), (6, float('nan'), 'nan'),
+             (7, float('inf'), 'infinity'), (8, float('nan'), 'nan-first'),
+             (9, float('-inf'), 'negative-infinity'), (10, None, 
'null-first')],
+            [(1, float('nan'), 'nan'), (2, 1.0, 'finite'),
+             (3, -0.0, 'negative-zero'), (4, 0.0, 'positive-zero'),
+             (5, float('nan'), 'nan'), (6, None, 'null'),
+             (7, float('nan'), 'nan'), (8, -float('nan'), 'nan-last'),
+             (9, 1.0, 'finite'), (10, None, 'null-last')],
+        ]
+        groups = [versions[0] + versions[1]] if grouping == 'batch' else 
versions
+        for rows in groups:
+            builder = table.new_batch_write_builder()
+            writer, commit = builder.new_write(), builder.new_commit()
+            try:
+                writer.write_arrow(pa.Table.from_pydict({
+                    field.name: [row[i] for row in rows]
+                    for i, field in enumerate(arrow_schema)
+                }, schema=arrow_schema))
+                commit.commit(writer.prepare_commit())
+            finally:
+                writer.close()
+                commit.close()
+
     @parameterized.expand(get_file_format_params())
     def test_py_write_read_append_table(self, file_format):
         pa_schema = pa.schema([
diff --git a/paimon-python/pypaimon/tests/native_read_test.py 
b/paimon-python/pypaimon/tests/native_read_test.py
index e078b640f9..f57c9deff0 100644
--- a/paimon-python/pypaimon/tests/native_read_test.py
+++ b/paimon-python/pypaimon/tests/native_read_test.py
@@ -35,6 +35,7 @@ def _table_read(limit=None):
     read = TableRead.__new__(TableRead)
     read.table = Mock()
     read.table.options.native_read_enabled.return_value = True
+    read.table.options.sequence_field.return_value = []
     read.table.options.file_format.return_value = 'parquet'
     read.table.options.data_file_path_directory.return_value = None
     read.table.options.blob_as_descriptor.return_value = False
@@ -68,6 +69,19 @@ def _id_batch(values):
         [pa.array(values, type=pa.int32())], names=['id'])
 
 
[email protected]('type_', ['FLOAT', 'DOUBLE'])
+def test_floating_sequence_falls_back_before_native_read(type_):
+    read = _table_read()
+    read.table.is_primary_key_table = True
+    read.table.options.sequence_field.return_value = ['seq']
+    read.table.field_dict = {'seq': DataField(1, 'seq', AtomicType(type_))}
+    split = _Split()
+    split._native_split = object()
+    with patch('pypaimon.read.native_plan.native_read') as native:
+        assert read._try_native_batches([split], pa.schema([('id', 
pa.int32())])) is None
+        native.assert_not_called()
+
+
 def test_native_read_consumes_retained_rust_splits_and_enforces_limit():
     read = _table_read(limit=2)
     first, second = _Split(), _Split()
diff --git a/paimon-python/pypaimon/tests/native_write_test.py 
b/paimon-python/pypaimon/tests/native_write_test.py
index 1b0f4972f3..73c555badc 100644
--- a/paimon-python/pypaimon/tests/native_write_test.py
+++ b/paimon-python/pypaimon/tests/native_write_test.py
@@ -30,7 +30,7 @@ from pypaimon.write.native_write import NativeTableWrite
 requires_native = pytest.mark.native_plan
 
 
-def _table(tmp_path, primary_key=False, commit_native=True, 
table_options=None):
+def _table(tmp_path, primary_key=False, commit_native=True, 
table_options=None, id_type=None):
     catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
     catalog.create_database('default', True)
     options = {'file.format': 'parquet', 'write.native.enabled': 'true',
@@ -39,7 +39,7 @@ def _table(tmp_path, primary_key=False, commit_native=True, 
table_options=None):
         options['bucket'] = '1'
     options.update(table_options or {})
     catalog.create_table('default.t', Schema.from_pyarrow_schema(
-        pa.schema([('id', pa.int64()), ('pt', pa.string())]),
+        pa.schema([('id', id_type if id_type is not None else pa.int64()), 
('pt', pa.string())]),
         options=options, primary_keys=['id'] if primary_key else [],
         partition_keys=[] if primary_key else ['pt']), False)
     return catalog.get_table('default.t')
@@ -63,6 +63,44 @@ def test_native_write_is_opt_in():
     assert CoreOptions(Options({'write.native.enabled': 
'true'})).native_write_enabled()
 
 
[email protected]('streaming', [False, True])
[email protected]('sequence', ['missing', 'id,id', 'id,,pt'])
+def test_sequence_validation_precedes_native_selection(tmp_path, streaming, 
sequence):
+    table = _table(tmp_path, primary_key=True, 
table_options={'sequence.field': sequence})
+    builder = (table.new_stream_write_builder() if streaming
+               else table.new_batch_write_builder())
+    # A usable native backend must not bypass validation. This also runs when
+    # the optional Rust extension is absent.
+    with patch('pypaimon.write.native_write.create_native_write', 
return_value=object()) as native:
+        with pytest.raises(ValueError):
+            builder.new_write()
+        native.assert_not_called()
+
+
[email protected]('type_,order,supported', [
+    (pa.int64(), 'ascending', True),
+    (pa.int64(), 'descending', False),
+    (pa.float32(), 'ascending', False),
+    (pa.float64(), 'ascending', False),
+])
+def test_native_sequence_write_capabilities(tmp_path, type_, order, supported):
+    from pypaimon.write.native_write import create_native_write
+
+    table = _table(tmp_path, primary_key=True, id_type=type_, table_options={
+        'sequence.field': 'id', 'sequence.field.sort-order': order,
+    })
+    with patch('pypaimon.write.native_write.native_write_available', 
return_value=True), \
+            patch('pypaimon.write.native_write.create_native_write_table') as 
native:
+        writer = create_native_write(table, 'test')
+        if supported:
+            assert isinstance(writer, NativeTableWrite)
+            native.assert_called_once()
+            writer.close()
+        else:
+            assert writer is None
+            native.assert_not_called()
+
+
 @requires_native
 @pytest.mark.parametrize('primary_key', [False, True])
 @pytest.mark.parametrize('commit_native', [False, True])
diff --git a/paimon-python/pypaimon/tests/test_aggregation_e2e.py 
b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
index da22421475..c7bce9dba5 100644
--- a/paimon-python/pypaimon/tests/test_aggregation_e2e.py
+++ b/paimon-python/pypaimon/tests/test_aggregation_e2e.py
@@ -232,8 +232,12 @@ class AggregationMergeEngineE2ETest(unittest.TestCase):
         table = self._create_pk_table(
             table_name, extra_options=extra_options
         )
-        # Writing is fine — the guard fires when a reader is built.
-        self._write(table, [{'id': 1, 'total': 1, 'max_score': 1, 'label': 
'a'}])
+        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)
         rb = table.new_read_builder()
         with self.assertRaises(error_type) as cm:
             rb.new_read()
diff --git a/paimon-python/pypaimon/tests/test_sequence_field_read.py 
b/paimon-python/pypaimon/tests/test_sequence_field_read.py
index ea6df67f3e..ff7c2933b5 100644
--- a/paimon-python/pypaimon/tests/test_sequence_field_read.py
+++ b/paimon-python/pypaimon/tests/test_sequence_field_read.py
@@ -268,7 +268,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
         table = self._create_pk_table(
             'seq_fr', merge_engine='first-row',
             extra_options={'sequence.field': 'ts'})
-        self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'high'}])
+        with self.assertRaises(ValueError):
+            self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'high'}])
         with self.assertRaises(ValueError) as ctx:
             table.new_read_builder().new_read()
         self.assertIn('FIRST_ROW', str(ctx.exception))
@@ -301,7 +302,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
         """
         table = self._create_pk_table(
             'seq_missing', extra_options={'sequence.field': 'nope'})
-        self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
+        with self.assertRaises(ValueError):
+            self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
         with self.assertRaises(ValueError) as ctx:
             table.new_read_builder().new_read()
         self.assertIn('nope', str(ctx.exception))
@@ -312,7 +314,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
         """
         table = self._create_pk_table(
             'seq_dup', extra_options={'sequence.field': 'ts,ts'})
-        self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
+        with self.assertRaises(ValueError):
+            self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
         with self.assertRaises(ValueError) as ctx:
             table.new_read_builder().new_read()
         self.assertIn('ts', str(ctx.exception))
@@ -326,7 +329,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
         """
         table = self._create_pk_table(
             'seq_empty_seg', extra_options={'sequence.field': 'ts,,ts2'})
-        self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
+        with self.assertRaises(ValueError):
+            self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
         with self.assertRaises(ValueError) as ctx:
             table.new_read_builder().new_read()
         # The empty field name is the one that can't be found in the schema.
@@ -340,7 +344,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
         table = self._create_pk_table(
             'seq_xpart', extra_options={'sequence.field': 'ts'},
             partition_keys=['ts2'])
-        self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
+        with self.assertRaises(ValueError):
+            self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
         with self.assertRaises(ValueError) as ctx:
             table.new_read_builder().new_read()
         self.assertIn('cross partition', str(ctx.exception).lower())
@@ -356,7 +361,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
             'seq_agg_on_seq', merge_engine='aggregation',
             extra_options={'sequence.field': 'ts',
                            'fields.ts.aggregate-function': 'sum'})
-        self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
+        with self.assertRaises(ValueError):
+            self._write(table, [{'id': 1, 'ts': 100, 'ts2': 0, 'val': 'x'}])
         with self.assertRaises(ValueError) as ctx:
             table.new_read_builder().new_read()
         self.assertIn('fields.ts.aggregate-function', str(ctx.exception))
@@ -421,15 +427,8 @@ class SequenceFieldReadE2ETest(unittest.TestCase):
         self.catalog.create_table('default.seq_complex', schema, False)
         table = self.catalog.get_table('default.seq_complex')
         wb = table.new_batch_write_builder()
-        w = wb.new_write()
-        c = wb.new_commit()
-        try:
-            w.write_arrow(pa.Table.from_pylist(
-                [{'id': 1, 'seq': [1, 2], 'val': 'x'}], schema=pa_schema))
-            c.commit(w.prepare_commit())
-        finally:
-            w.close()
-            c.close()
+        with self.assertRaises(NotImplementedError):
+            wb.new_write()
         with self.assertRaises(NotImplementedError) as ctx:
             table.new_read_builder().new_read()
         self.assertIn('seq', str(ctx.exception))
diff --git a/paimon-python/pypaimon/tests/test_sequence_field_write.py 
b/paimon-python/pypaimon/tests/test_sequence_field_write.py
new file mode 100644
index 0000000000..58004b5401
--- /dev/null
+++ b/paimon-python/pypaimon/tests/test_sequence_field_write.py
@@ -0,0 +1,249 @@
+################################################################################
+#  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 datetime
+from decimal import Decimal
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.table.row.generic_row import GenericRow
+
+
+pytestmark = pytest.mark.python_write
+
+
[email protected]
+def make_table(tmp_path):
+    catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
+    catalog.create_database('default', True)
+
+    def create(options=None, sequence_type=None, partitioned=False):
+        arrow_schema = pa.schema([
+            pa.field('id', pa.int64(), nullable=False),
+            ('seq', sequence_type if sequence_type is not None else 
pa.int64()),
+            ('seq2', pa.int64()), ('val', pa.string()), ('pt', pa.string()),
+        ])
+        opts = {'bucket': '1', 'sequence.field': 'seq'}
+        opts.update(options or {})
+        catalog.create_table('default.t', Schema.from_pyarrow_schema(
+            arrow_schema, primary_keys=['pt', 'id'] if partitioned else ['id'],
+            partition_keys=['pt'] if partitioned else [], options=opts), False)
+        return catalog.get_table('default.t'), arrow_schema
+
+    return create
+
+
+def write_rows(table, schema, rows, grouping='batch', streaming=False):
+    builder = (table.new_stream_write_builder() if streaming
+               else table.new_batch_write_builder())
+    commit = builder.new_commit()
+    groups = [[row] for row in rows] if grouping == 'commits' else [rows]
+    try:
+        for identifier, group in enumerate(groups, 1):
+            writer = builder.new_write()
+            try:
+                batch = pa.Table.from_pylist(group, schema=schema)
+                if grouping == 'chunks':
+                    for chunk in batch.to_batches(max_chunksize=1):
+                        writer.write_arrow_batch(chunk)
+                elif grouping == 'rows':
+                    for row in group:
+                        writer.write_row(GenericRow(
+                            [row.get(field.name) for field in table.fields], 
table.fields))
+                else:
+                    writer.write_arrow(batch)
+                if streaming:
+                    commit.commit(writer.prepare_commit(identifier), 
identifier)
+                else:
+                    commit.commit(writer.prepare_commit())
+                    commit.close()
+                    commit = builder.new_commit()
+            finally:
+                writer.close()
+    finally:
+        commit.close()
+
+
+def read_rows(table, projection=None):
+    builder = table.new_read_builder()
+    if projection is not None:
+        builder.with_projection(projection)
+    return 
builder.new_read().to_arrow(builder.new_scan().plan().splits()).sort_by('id').to_pylist()
+
+
[email protected]('engine', ['deduplicate', 'partial-update'])
[email protected]('grouping', ['batch', 'chunks', 'rows', 'commits'])
[email protected]('order', ['ascending', 'descending'])
[email protected]('streaming', [False, True])
+def test_sequence_order_is_independent_of_write_grouping(
+        make_table, engine, grouping, order, streaming):
+    table, schema = make_table({'merge-engine': engine, 
'sequence.field.sort-order': order})
+    rows = [
+        {'id': 1, 'seq': 100, 'val': 'high'},
+        {'id': 2, 'seq': 100, 'val': 'tie-first'},
+        {'id': 3, 'seq': 100, 'val': 'non-null'},
+        {'id': 4, 'seq': None, 'val': 'null-first'},
+        {'id': 1, 'seq': 50, 'val': 'low'},
+        {'id': 2, 'seq': 100, 'val': 'tie-last'},
+        {'id': 3, 'seq': None, 'val': 'null'},
+        {'id': 4, 'seq': None, 'val': 'null-last'},
+    ]
+    write_rows(table, schema, rows, grouping, streaming)
+    assert read_rows(table, ['id', 'val']) == [
+        {'id': 1, 'val': 'high' if order == 'ascending' else 'low'},
+        {'id': 2, 'val': 'tie-last'},
+        {'id': 3, 'val': 'non-null'},
+        {'id': 4, 'val': 'null-last'},
+    ]
+
+
+def test_partial_update_fills_nulls_in_sequence_order(make_table):
+    table, schema = make_table({'merge-engine': 'partial-update'})
+    write_rows(table, schema, [
+        {'id': 1, 'seq': 100, 'seq2': 2, 'val': None},
+        {'id': 1, 'seq': 50, 'seq2': 1, 'val': 'filled'},
+    ], 'chunks')
+    assert read_rows(table, ['id', 'seq', 'seq2', 'val']) == [
+        {'id': 1, 'seq': 100, 'seq2': 2, 'val': 'filled'},
+    ]
+
+
+def test_sequence_order_across_buffer_flushes(make_table):
+    table, schema = make_table({'target-file-size': '1 b'})
+    write_rows(table, schema, [
+        {'id': 1, 'seq': 100, 'val': 'high'},
+        {'id': 1, 'seq': 50, 'val': 'low'},
+    ], 'chunks')
+    assert read_rows(table, ['id', 'val']) == [{'id': 1, 'val': 'high'}]
+
+
[email protected]('order,expected', [('ascending', 'high'), 
('descending', 'low')])
+def test_compound_sequence_in_partitioned_table(make_table, order, expected):
+    table, schema = make_table({
+        'sequence.field': 'seq,seq2', 'sequence.field.sort-order': order,
+    }, partitioned=True)
+    rows = []
+    for pt in ['a', 'b']:
+        rows.extend([
+            {'id': 1, 'pt': pt, 'seq': 10, 'seq2': 2, 'val': 'high'},
+            {'id': 1, 'pt': pt, 'seq': 9, 'seq2': 99, 'val': 'low'},
+            {'id': 1, 'pt': pt, 'seq': 10, 'seq2': 1, 'val': 'middle'},
+            {'id': 1, 'pt': pt, 'seq': 10, 'seq2': None, 'val': 'null'},
+        ])
+    write_rows(table, schema, rows, 'chunks')
+    assert sorted(read_rows(table, ['id', 'pt', 'val']), key=lambda row: 
row['pt']) == [
+        {'id': 1, 'pt': pt, 'val': expected} for pt in ['a', 'b']
+    ]
+
+
[email protected]('type_,high,low', [
+    (pa.decimal128(10, 2), Decimal('100.50'), Decimal('50.25')),
+    (pa.timestamp('us'), datetime.datetime(2020, 1, 2), 
datetime.datetime(2020, 1, 1)),
+    (pa.date32(), datetime.date(2020, 1, 2), datetime.date(2020, 1, 1)),
+    (pa.time32('ms'), datetime.time(12, 0), datetime.time(1, 0)),
+    (pa.float64(), 2.5, -1.0),
+    (pa.binary(), b'z', b'a'),
+])
+def test_typed_sequence_fields(make_table, type_, high, low):
+    table, schema = make_table(sequence_type=type_)
+    write_rows(table, schema, [
+        {'id': 1, 'seq': high, 'val': 'high'},
+        {'id': 1, 'seq': low, 'val': 'low'},
+    ])
+    assert read_rows(table)[0]['seq'] == high
+    assert read_rows(table, ['id', 'val']) == [{'id': 1, 'val': 'high'}]
+
+
[email protected]('type_', [pa.float32(), pa.float64()])
[email protected]('order', ['ascending', 'descending'])
[email protected]('grouping', ['batch', 'chunks', 'commits'])
[email protected]('streaming', [False, True])
+def test_floating_sequence_order(make_table, type_, order, grouping, 
streaming):
+    table, schema = make_table({
+        'sequence.field.sort-order': order,
+        'write.native.enabled': 'true', 'read.native.enabled': 'true',
+    }, sequence_type=type_)
+    # Pairs are in Java Float/Double.compare order, with null always first.
+    pairs = [
+        (1.0, float('nan')), (float('inf'), float('nan')),
+        (float('-inf'), 1.0), (1.0, float('inf')),
+        (-0.0, 0.0), (None, float('nan')), (None, 0.0),
+    ]
+    rows, expected = [], []
+    for low, high in pairs:
+        for reverse in (False, True):
+            key = len(expected)
+            pair = [{'id': key, 'seq': low, 'val': 'low'},
+                    {'id': key, 'seq': high, 'val': 'high'}]
+            rows.extend(reversed(pair) if reverse else pair)
+            expected.append({'id': key, 'val': 'high' if low is None or order 
== 'ascending'
+                             else 'low'})
+    for values in ((float('nan'), -float('nan')), (None, None), (-0.0, -0.0)):
+        key = len(expected)
+        rows.extend({'id': key, 'seq': value, 'val': label}
+                    for value, label in zip(values, ('first', 'last')))
+        expected.append({'id': key, 'val': 'last'})
+    write_rows(table, schema, rows, grouping, streaming)
+    assert read_rows(table, ['id', 'val']) == expected
+
+
[email protected]('order', ['ascending', 'descending'])
[email protected]('grouping', ['batch', 'commits'])
+def test_nan_ties_compare_next_sequence_field(make_table, order, grouping):
+    table, schema = make_table({
+        'sequence.field': 'seq,seq2', 'sequence.field.sort-order': order,
+        'write.native.enabled': 'true', 'read.native.enabled': 'true',
+    }, sequence_type=pa.float64())
+    write_rows(table, schema, [
+        {'id': 1, 'seq': float('nan'), 'seq2': 2, 'val': 'high'},
+        {'id': 1, 'seq': -float('nan'), 'seq2': 1, 'val': 'low'},
+    ], grouping)
+    assert read_rows(table, ['id', 'val']) == [
+        {'id': 1, 'val': 'high' if order == 'ascending' else 'low'}]
+
+
[email protected]('streaming', [False, True])
[email protected]('direct', [False, True])
[email protected]('sequence,type_,error', [
+    ('missing', pa.int64(), ValueError),
+    ('seq,seq', pa.int64(), ValueError),
+    ('seq,,seq2', pa.int64(), ValueError),
+    ('seq', pa.list_(pa.int64()), NotImplementedError),
+])
+def test_dynamic_bucket_sequence_rejected_before_index_creation(
+        make_table, streaming, direct, sequence, type_, error):
+    from pathlib import Path
+    from pypaimon.write.table_write import BatchTableWrite, StreamTableWrite
+
+    table, _ = make_table({'bucket': '-1', 'sequence.field': sequence}, 
sequence_type=type_)
+    builder = (table.new_stream_write_builder() if streaming
+               else table.new_batch_write_builder())
+    writer_class = StreamTableWrite if streaming else BatchTableWrite
+    with patch.object(table, 'create_row_key_extractor') as extractor:
+        with pytest.raises(error):
+            if direct:
+                writer_class(table, 'test')
+            else:
+                builder.new_write()
+        extractor.assert_not_called()
+    assert table.snapshot_manager().get_latest_snapshot() is None
+    assert list(Path(table.table_path).glob('index/*')) == []
+    assert list(Path(table.table_path).rglob('*.parquet')) == []
diff --git a/paimon-python/pypaimon/tests/test_write_merge_buffer.py 
b/paimon-python/pypaimon/tests/test_write_merge_buffer.py
index b3ee177a39..42428e6388 100644
--- a/paimon-python/pypaimon/tests/test_write_merge_buffer.py
+++ b/paimon-python/pypaimon/tests/test_write_merge_buffer.py
@@ -31,6 +31,8 @@ from unittest.mock import Mock
 
 import pyarrow as pa
 
+from pypaimon.common.options.core_options import CoreOptions
+from pypaimon.common.options.options import Options
 from pypaimon.read.reader.deduplicate_merge_function import \
     DeduplicateMergeFunction
 from pypaimon.read.reader.partial_update_merge_function import \
@@ -83,6 +85,7 @@ class _Harness(KeyValueDataWriter):
     """
 
     def __init__(self, merge_function, target_file_size: int = 10 ** 12):
+        self.options = CoreOptions(Options({}))
         self.trimmed_primary_keys = ['id']
         self._merge_function = merge_function
         # Large enough that ``_check_and_roll_if_needed`` does not
diff --git a/paimon-python/pypaimon/write/file_store_write.py 
b/paimon-python/pypaimon/write/file_store_write.py
index a73b9df77d..596cd8594d 100644
--- a/paimon-python/pypaimon/write/file_store_write.py
+++ b/paimon-python/pypaimon/write/file_store_write.py
@@ -41,6 +41,11 @@ class FileStoreWrite:
 
     def __init__(self, table, commit_user):
         from pypaimon.table.file_store_table import FileStoreTable
+        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)
 
         self.table: FileStoreTable = table
         self.data_writers: Dict[Tuple, DataWriter] = {}
diff --git a/paimon-python/pypaimon/write/native_write.py 
b/paimon-python/pypaimon/write/native_write.py
index f96905f3e7..5185731614 100644
--- a/paimon-python/pypaimon/write/native_write.py
+++ b/paimon-python/pypaimon/write/native_write.py
@@ -42,6 +42,13 @@ def native_write_available() -> bool:
 def create_native_write(table, commit_user, static_partition=None, 
stream=False):
     """Return a native writer if the table can use the filesystem write 
path."""
     schema = PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    sequence_fields = table.options.sequence_field()
+    if table.is_primary_key_table and sequence_fields:
+        # The native writer currently sorts sequence fields ascending and
+        # does not implement Java's NaN/signed-zero ordering.
+        if (not table.options.sequence_field_sort_order_is_ascending()
+                or any(pa.types.is_floating(schema.field(name).type) for name 
in sequence_fields)):
+            return None
     partition_types = [schema.field(name).type for name in 
table.partition_keys]
     if (not native_write_available()
             or table.options.data_evolution_enabled()
diff --git a/paimon-python/pypaimon/write/write_builder.py 
b/paimon-python/pypaimon/write/write_builder.py
index 0586ff2d73..0f79308ab9 100644
--- a/paimon-python/pypaimon/write/write_builder.py
+++ b/paimon-python/pypaimon/write/write_builder.py
@@ -54,6 +54,11 @@ class WriteBuilder(ABC):
             return str(uuid.uuid4())
 
     def _native_write(self, static_partition=None, stream=False):
+        from pypaimon.read.merge_engine_support import 
check_sequence_field_supported
+
+        # Keep invalid configurations outside the native fallback handler and
+        # enforce the same contract regardless of which writer is selected.
+        check_sequence_field_supported(self.table)
         if not self.table.options.native_write_enabled():
             return None
         # data-file.path-directory relocates data files under a sub-directory
diff --git a/paimon-python/pypaimon/write/writer/key_value_data_writer.py 
b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
index 1f1537f6d1..6992852bbc 100644
--- a/paimon-python/pypaimon/write/writer/key_value_data_writer.py
+++ b/paimon-python/pypaimon/write/writer/key_value_data_writer.py
@@ -17,6 +17,7 @@
 
 from typing import List, Union
 
+import numpy as np
 import pyarrow as pa
 import pyarrow.compute as pc
 
@@ -157,7 +158,7 @@ class KeyValueDataWriter(DataWriter):
         """Fold same-PK runs in ``data`` using ``self._merge_function``.
 
         ``data`` is required to already be sorted by
-        ``(primary_key, _SEQUENCE_NUMBER)``. ``_flush_all`` is the
+        ``(primary_key, sequence.field, _SEQUENCE_NUMBER)``. ``_flush_all`` is 
the
         only caller and runs ``_sort_by_primary_key`` immediately
         before this method, so the precondition holds.
 
@@ -285,12 +286,42 @@ class KeyValueDataWriter(DataWriter):
         # pc.sort_indices + .take work uniformly over RecordBatch and
         # Table, so this serves both the per-batch entry path (legacy)
         # and the buffer-wide sort path (used by ``_flush_all``).
-        sort_keys = [(key, 'ascending') for key in self.trimmed_primary_keys]
+        sort_columns = [data.column(key) for key in self.trimmed_primary_keys]
+        sort_orders = ['ascending'] * len(sort_columns)
+        sequence_fields = self.options.sequence_field()
+        if sequence_fields:
+            sequence_order = ('ascending' if 
self.options.sequence_field_sort_order_is_ascending()
+                              else 'descending')
+            for field in sequence_fields:
+                column = data.column(field)
+                if pa.types.is_floating(column.type):
+                    column = self._floating_sequence_sort_key(column)
+                sort_columns.append(column)
+                sort_orders.append(sequence_order)
         if '_SEQUENCE_NUMBER' in data.schema.names:
-            sort_keys.append(('_SEQUENCE_NUMBER', 'ascending'))
+            sort_columns.append(data.column('_SEQUENCE_NUMBER'))
+            sort_orders.append('ascending')
 
+        # Sort a separate key table so temporary keys cannot collide with user
+        # column names or change the stored values (including signed zero).
+        names = [str(i) for i in range(len(sort_columns))]
         # Java MergeTree comparators order null keys first. Keep 
Python-written files in the same
         # order so their key ranges and sorted-run invariants are 
interoperable with Java readers.
         sorted_indices = pc.sort_indices(
-            data, sort_keys=sort_keys, null_placement='at_start')
+            pa.table(sort_columns, names=names),
+            sort_keys=list(zip(names, sort_orders)), null_placement='at_start')
         return data.take(sorted_indices)
+
+    @staticmethod
+    def _floating_sequence_sort_key(column):
+        """Unsigned keys in Java Float/Double.compare order, preserving 
nulls."""
+        # The NumPy protocol supports Array and older ChunkedArray APIs alike.
+        values = np.asarray(column)
+        bits = values.view(np.dtype('uint{}'.format(column.type.bit_width)))
+        sign_bit = np.array(1 << (column.type.bit_width - 1), dtype=bits.dtype)
+        # Invert negative IEEE bits, flip the sign bit for nonnegative values.
+        # This orders -0.0 before +0.0, unlike Arrow's floating-point sort.
+        keys = np.where(bits & sign_bit, ~bits, bits ^ sign_bit)
+        # All NaN signs/payloads compare equal, above positive infinity.
+        keys[np.isnan(values)] = np.iinfo(bits.dtype).max
+        return pa.array(keys, mask=np.asarray(column.is_null()))

Reply via email to