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

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


The following commit(s) were added to refs/heads/main by this push:
     new 86adc63f [core] Support configurable sort order for partial-update 
sequence groups (#1226)
86adc63f is described below

commit 86adc63f54d34f1889a1aa33513006ff1899c0ed
Author: Ran Tao <[email protected]>
AuthorDate: Tue Oct 27 16:10:21 2026 +0800

    [core] Support configurable sort order for partial-update sequence groups 
(#1226)
---
 bindings/python/tests/test_write.py       |  54 ++++++
 crates/paimon/src/spec/aggregation.rs     |   9 +-
 crates/paimon/src/spec/partial_update.rs  | 101 +++++++++++
 crates/paimon/src/spec/schema.rs          |  24 ++-
 crates/paimon/src/table/kv_file_reader.rs | 110 +++++++++++
 crates/paimon/src/table/kv_file_writer.rs | 292 ++++++++++++++++--------------
 crates/paimon/src/table/sort_merge.rs     | 159 +++++++++++++++-
 docs/src/sql.md                           |  32 +++-
 8 files changed, 626 insertions(+), 155 deletions(-)

diff --git a/bindings/python/tests/test_write.py 
b/bindings/python/tests/test_write.py
index 14284b3f..4ee40bbf 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -64,6 +64,60 @@ def test_write_commit_read_roundtrip():
         assert result == {"id": [1, 2, 3], "name": ["a", "b", "c"]}
 
 
[email protected]("direction", [None, "ascending", "descending", 
"DeScEnDiNg"])
[email protected]("separate_commits", [False, True])
[email protected]("reverse_input", [False, True])
+def test_partial_update_sequence_group_sort_order(
+        tmp_path, direction, separate_commits, reverse_input):
+    # apache/paimon#10546: retain the latest status and the earliest channel.
+    ctx = SQLContext()
+    ctx.register_catalog("paimon", {"warehouse": str(tmp_path)})
+    ctx.sql("CREATE SCHEMA paimon.wdb")
+    sort_option = ("" if direction is None else
+                   f", 'fields.first_event_time.sequence-group.sort-order' = 
'{direction}'")
+    ctx.sql(
+        "CREATE TABLE paimon.wdb.t (user_id BIGINT, status STRING, "
+        "status_version BIGINT, channel STRING, first_event_time BIGINT, "
+        "PRIMARY KEY (user_id)) WITH ("
+        "'bucket' = '1', 'merge-engine' = 'partial-update', "
+        "'fields.status_version.sequence-group' = 'status', "
+        "'fields.first_event_time.sequence-group' = 'channel'" + sort_option + 
")")
+    schema = pa.schema([
+        ("user_id", pa.int64()), ("status", pa.string()),
+        ("status_version", pa.int64()), ("channel", pa.string()),
+        ("first_event_time", pa.int64())])
+    rows = [dict(zip(schema.names, row)) for row in [
+        (1, "active", 200, "organic", 100),
+        (1, "registered", 100, "referral", 50)]]
+    if reverse_input:
+        rows.reverse()
+    table = _get_table(str(tmp_path))
+    for group in ([[row] for row in rows] if separate_commits else [rows]):
+        builder = table.new_batch_write_builder()
+        writer = builder.new_write()
+        try:
+            writer.write_arrow(pa.RecordBatch.from_pylist(group, 
schema=schema))
+            builder.new_commit().commit(writer.prepare_commit())
+        finally:
+            writer.close()
+
+    descending = direction is not None and direction.lower() == "descending"
+    channel, event_time = ("referral", 50) if descending else ("organic", 100)
+    expected = [dict(zip(schema.names, (1, "active", 200, channel, 
event_time)))]
+    assert pa.Table.from_batches(ctx.sql("SELECT * FROM 
paimon.wdb.t")).to_pylist() == expected
+    # Both the native read builder and SQL projection must retain ordering 
dependencies.
+    read_builder = _get_table(str(tmp_path)).new_read_builder()
+    read_builder.with_projection(["channel"])
+    batches = 
read_builder.new_read().read(read_builder.new_scan().plan().splits())
+    assert pa.Table.from_batches(batches).to_pylist() == [{"channel": channel}]
+    assert pa.Table.from_batches(ctx.sql(
+        "SELECT channel FROM paimon.wdb.t")).to_pylist() == [{"channel": 
channel}]
+    ctx.sql("ALTER TABLE paimon.wdb.t RENAME COLUMN first_event_time TO 
event_time")
+    assert pa.Table.from_batches(ctx.sql(
+        "SELECT channel, event_time FROM paimon.wdb.t")).to_pylist() == [
+            {"channel": channel, "event_time": event_time}]
+
+
 @pytest.mark.parametrize("streaming", [False, True])
 def test_partial_write_type_roundtrip_and_lifecycle(tmp_path, streaming):
     ctx = SQLContext()
diff --git a/crates/paimon/src/spec/aggregation.rs 
b/crates/paimon/src/spec/aggregation.rs
index 9a05e68f..fd90da27 100644
--- a/crates/paimon/src/spec/aggregation.rs
+++ b/crates/paimon/src/spec/aggregation.rs
@@ -17,6 +17,7 @@
 
 use std::collections::HashMap;
 
+use super::partial_update::SEQUENCE_GROUP_SORT_ORDER_SUFFIX;
 use crate::spec::core_options::IGNORE_DELETE_FALLBACK_KEYS;
 use crate::spec::{CoreOptions, DataField, DataType, VarCharType};
 
@@ -318,6 +319,7 @@ const FIELD_SCOPED_FIELD_LIST_SUFFIXES: [&str; 2] = 
[SEQUENCE_GROUP_SUFFIX, NEST
 /// - case 2: `fields.<old>.<suffix>` -> `fields.<new>.<suffix>`, value kept
 /// - case 3 (`sequence-group`, `nested-key`): every comma-separated entry
 ///   equal to `old` is renamed in BOTH the key's field list and the value
+/// - `sequence-group.sort-order`: rename the key's field list only
 pub(crate) fn rename_field_scoped_options(
     options: &mut HashMap<String, String>,
     old: &str,
@@ -336,13 +338,18 @@ pub(crate) fn rename_field_scoped_options(
     for (key, value) in options.iter() {
         let Some(suffix) = FIELD_SCOPED_FIELD_LIST_SUFFIXES
             .iter()
+            .chain(std::iter::once(&SEQUENCE_GROUP_SORT_ORDER_SUFFIX))
             .find(|suffix| key.starts_with(FIELDS_PREFIX) && 
key.ends_with(**suffix))
         else {
             continue;
         };
         let key_fields = &key[FIELDS_PREFIX.len()..key.len() - suffix.len()];
         let new_key_fields = rename_in_field_list(key_fields, old, new);
-        let new_value = rename_in_field_list(value, old, new);
+        let new_value = if *suffix == SEQUENCE_GROUP_SORT_ORDER_SUFFIX {
+            value.clone()
+        } else {
+            rename_in_field_list(value, old, new)
+        };
         if new_key_fields != key_fields || new_value != *value {
             rewritten.push((
                 key.clone(),
diff --git a/crates/paimon/src/spec/partial_update.rs 
b/crates/paimon/src/spec/partial_update.rs
index 88e253bf..447b8123 100644
--- a/crates/paimon/src/spec/partial_update.rs
+++ b/crates/paimon/src/spec/partial_update.rs
@@ -32,6 +32,7 @@ const PARTIAL_UPDATE_REMOVE_RECORD_ON_SEQUENCE_GROUP_OPTION: 
&str =
 const FIELDS_DEFAULT_AGG_FUNCTION_OPTION: &str = 
"fields.default-aggregate-function";
 const FIELDS_PREFIX: &str = "fields.";
 const SEQUENCE_GROUP_SUFFIX: &str = ".sequence-group";
+pub(super) const SEQUENCE_GROUP_SORT_ORDER_SUFFIX: &str = 
".sequence-group.sort-order";
 const AGGREGATION_FUNCTION_SUFFIX: &str = ".aggregate-function";
 const LIST_AGG_DELIMITER_SUFFIX: &str = ".list-agg-delimiter";
 const IGNORE_RETRACT_SUFFIX: &str = ".ignore-retract";
@@ -50,6 +51,7 @@ pub(crate) enum PartialUpdateMode {
 pub(crate) struct SequenceGroup {
     pub(crate) sequence_fields: Vec<String>,
     pub(crate) protected_fields: Vec<String>,
+    pub(crate) ascending: bool,
 }
 
 impl SequenceGroup {
@@ -84,6 +86,7 @@ impl<'a> PartialUpdateConfig<'a> {
         &self,
         has_primary_keys: bool,
     ) -> crate::Result<Option<PartialUpdateMode>> {
+        self.validate_sequence_group_sort_orders(has_primary_keys)?;
         match self.validated_mode(has_primary_keys) {
             Ok(mode) => Ok(mode),
             Err(unsupported_options) => Err(crate::Error::ConfigInvalid {
@@ -114,6 +117,7 @@ impl<'a> PartialUpdateConfig<'a> {
         has_primary_keys: bool,
         table_name: &str,
     ) -> crate::Result<Option<PartialUpdateMode>> {
+        self.validate_sequence_group_sort_orders(has_primary_keys)?;
         match self.validated_mode(has_primary_keys) {
             Ok(mode) => {
                 if mode.is_some() { self.validate_delete_config()?; }
@@ -133,6 +137,7 @@ impl<'a> PartialUpdateConfig<'a> {
         has_primary_keys: bool,
         table_name: &str,
     ) -> crate::Result<Option<PartialUpdateMode>> {
+        self.validate_sequence_group_sort_orders(has_primary_keys)?;
         if !has_primary_keys || !self.is_enabled() {
             return Ok(None);
         }
@@ -156,6 +161,7 @@ impl<'a> PartialUpdateConfig<'a> {
     }
 
     pub(crate) fn sequence_groups(&self) -> crate::Result<Vec<SequenceGroup>> {
+        self.validate_sequence_group_sort_orders(true)?;
         let mut options: Vec<(&String, &String)> = self
             .options
             .iter()
@@ -179,11 +185,48 @@ impl<'a> PartialUpdateConfig<'a> {
                 Ok(SequenceGroup {
                     sequence_fields,
                     protected_fields,
+                    ascending: !self
+                        .options
+                        .get(&format!("{key}.sort-order"))
+                        .is_some_and(|value| 
value.eq_ignore_ascii_case("descending")),
                 })
             })
             .collect()
     }
 
+    fn validate_sequence_group_sort_orders(&self, has_primary_keys: bool) -> 
crate::Result<()> {
+        for (key, value) in self
+            .options
+            .iter()
+            .filter(|(key, _)| is_fields_option_with_suffix(key, 
SEQUENCE_GROUP_SORT_ORDER_SUFFIX))
+        {
+            if !has_primary_keys || !self.is_enabled() {
+                return Err(crate::Error::ConfigInvalid {
+                    message: format!(
+                        "Option '{key}' is only supported by primary-key 
tables using merge-engine=partial-update"
+                    ),
+                });
+            }
+            let group_key = key
+                .strip_suffix(".sort-order")
+                .expect("sort-order suffix checked");
+            if !self.options.contains_key(group_key) {
+                return Err(crate::Error::ConfigInvalid {
+                    message: format!(
+                        "Cannot set '{key}' without defining corresponding 
sequence group '{group_key}'"
+                    ),
+                });
+            }
+            if !value.eq_ignore_ascii_case("ascending") && 
!value.eq_ignore_ascii_case("descending")
+            {
+                return Err(crate::Error::ConfigInvalid {
+                    message: format!("Invalid value '{value}' for option 
'{key}': expected ascending or descending"),
+                });
+            }
+        }
+        Ok(())
+    }
+
     pub(crate) fn remove_record_on_delete(&self) -> bool {
         self.options
             .get(PARTIAL_UPDATE_REMOVE_RECORD_ON_DELETE_OPTION)
@@ -763,15 +806,73 @@ mod tests {
                 SequenceGroup {
                     sequence_fields: vec!["event_time".to_string(), 
"source_order".to_string()],
                     protected_fields: vec!["price".to_string(), 
"quantity".to_string()],
+                    ascending: true,
                 },
                 SequenceGroup {
                     sequence_fields: vec!["profile_version".to_string()],
                     protected_fields: vec!["name".to_string(), 
"address".to_string()],
+                    ascending: true,
                 },
             ]
         );
     }
 
+    #[test]
+    fn test_sequence_group_sort_order_validation() {
+        let group_key = "fields.event_time,source_order.sequence-group";
+        let sort_key = 
"fields.event_time,source_order.sequence-group.sort-order";
+        for (value, ascending) in [
+            ("ascending", true),
+            ("descending", false),
+            ("DeScEnDiNg", false),
+        ] {
+            let options = partial_update_options(&[(group_key, "price"), 
(sort_key, value)]);
+            let config = PartialUpdateConfig::new(&options);
+            assert_eq!(config.sequence_groups().unwrap()[0].ascending, 
ascending);
+            assert_eq!(
+                config.validate_create_mode(true).unwrap(),
+                Some(PartialUpdateMode::SequenceGroup)
+            );
+        }
+
+        for (options, has_pk, message) in [
+            (
+                partial_update_options(&[(group_key, "price"), (sort_key, 
"desc")]),
+                true,
+                "expected ascending or descending",
+            ),
+            (
+                partial_update_options(&[(sort_key, "descending")]),
+                true,
+                "corresponding sequence group",
+            ),
+            (
+                partial_update_options(&[(group_key, "price"), (sort_key, 
"descending")]),
+                false,
+                "primary-key tables",
+            ),
+            (
+                HashMap::from([
+                    (group_key.to_string(), "price".to_string()),
+                    (sort_key.to_string(), "descending".to_string()),
+                ]),
+                true,
+                "merge-engine=partial-update",
+            ),
+        ] {
+            let config = PartialUpdateConfig::new(&options);
+            for result in [
+                config.validate_create_mode(has_pk),
+                config.validate_read_mode(has_pk, "t"),
+                config.validate_write_mode(has_pk, "t"),
+            ] {
+                assert!(
+                    matches!(result, Err(crate::Error::ConfigInvalid { 
message: actual }) if actual.contains(message))
+                );
+            }
+        }
+    }
+
     #[test]
     fn test_parse_sequence_groups_rejects_empty_field_names() {
         for (key, value) in [
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index bf257eb7..85dd36a5 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -5147,9 +5147,6 @@ mod tests {
 
     #[test]
     fn test_rename_column_rewrites_sequence_group_options() {
-        // `sequence-group` is rejected by Rust's create-time merge-engine
-        // validation, so the fixture carries the option on a table without
-        // `merge-engine` — the shape in which Java-written metadata arrives.
         let table_schema = TableSchema::new(
             0,
             &Schema::builder()
@@ -5158,11 +5155,17 @@ mod tests {
                 .column("source_order", DataType::Int(IntType::new()))
                 .column("price", DataType::Int(IntType::new()))
                 .column("quantity", DataType::Int(IntType::new()))
+                .column("descending", DataType::Int(IntType::new()))
                 .primary_key(["id"])
+                .option("merge-engine", "partial-update")
                 .option(
                     "fields.version,source_order.sequence-group",
                     "price,quantity",
                 )
+                .option(
+                    "fields.version,source_order.sequence-group.sort-order",
+                    "descending",
+                )
                 .build()
                 .unwrap(),
         );
@@ -5175,6 +5178,11 @@ mod tests {
                     "order_seq".to_string(),
                 ),
                 crate::spec::SchemaChange::rename_column("price".to_string(), 
"amount".to_string()),
+                // The direction is a literal, even if a column shares its 
name.
+                crate::spec::SchemaChange::rename_column(
+                    "descending".to_string(),
+                    "direction".to_string(),
+                ),
             ])
             .unwrap();
 
@@ -5191,6 +5199,16 @@ mod tests {
                 .get("fields.version,source_order.sequence-group"),
             None
         );
+        assert_eq!(
+            new_schema
+                .options()
+                .get("fields.version,order_seq.sequence-group.sort-order")
+                .map(String::as_str),
+            Some("descending")
+        );
+        assert!(!new_schema
+            .options()
+            
.contains_key("fields.version,source_order.sequence-group.sort-order"));
     }
 
     #[test]
diff --git a/crates/paimon/src/table/kv_file_reader.rs 
b/crates/paimon/src/table/kv_file_reader.rs
index eda7a27c..e5486e9e 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -3477,6 +3477,116 @@ mod tests {
             .all(|batch| batch.schema().fields().len() == 1));
     }
 
+    #[tokio::test]
+    async fn kv_read_partial_update_row_sequence_group_sort_order() {
+        use crate::spec::RowType;
+        use arrow_buffer::NullBuffer;
+
+        // None is a top-level null; Some(None) is a non-null ROW(NULL).
+        // The final two entries select the winning row for 
ascending/descending.
+        let cases = [
+            (Some(Some(5)), Some(None), 0, 1),
+            (Some(None), Some(Some(5)), 1, 0),
+            (None, Some(Some(5)), 1, 1),
+            (Some(Some(5)), None, 0, 0),
+            (None, Some(None), 1, 1),
+            (Some(None), None, 0, 0),
+            (Some(Some(5)), Some(Some(2)), 0, 1),
+            (Some(None), Some(None), 1, 1),
+            (Some(Some(5)), Some(Some(5)), 1, 1),
+        ];
+        for direction in ["ascending", "descending"] {
+            for separate_commits in [false, true] {
+                for (first, second, ascending_winner, descending_winner) in 
cases {
+                    let file_io = test_file_io();
+                    let table_path = 
"memory:/kv_partial_update_row_sequence_group";
+                    setup_dirs(&file_io, table_path).await;
+                    let schema = Schema::builder()
+                        .column("id", DataType::Int(IntType::new()))
+                        .column(
+                            "seq",
+                            DataType::Row(RowType::new(vec![DataField::new(
+                                0,
+                                "version".into(),
+                                DataType::Int(IntType::new()),
+                            )])),
+                        )
+                        .column("value", DataType::Int(IntType::new()))
+                        .primary_key(["id"])
+                        .option("bucket", "1")
+                        .option("merge-engine", "partial-update")
+                        .option("fields.seq.sequence-group", "value")
+                        .option("fields.seq.sequence-group.sort-order", 
direction)
+                        .build()
+                        .unwrap();
+                    let table = Table::new(
+                        file_io,
+                        Identifier::new("default", 
"kv_partial_update_row_sequence_group"),
+                        table_path.to_string(),
+                        TableSchema::new(0, &schema),
+                        None,
+                    );
+                    let arrow_schema = 
build_target_arrow_schema(table.schema().fields()).unwrap();
+                    let ArrowDataType::Struct(sequence_fields) = 
arrow_schema.field(1).data_type()
+                    else {
+                        panic!("expected a ROW sequence field");
+                    };
+                    let sequences = [first, second];
+                    let sequence_array = StructArray::new(
+                        sequence_fields.clone(),
+                        vec![Arc::new(Int32Array::from(
+                            sequences
+                                .into_iter()
+                                .map(Option::flatten)
+                                .collect::<Vec<_>>(),
+                        ))],
+                        Some(NullBuffer::from(
+                            sequences
+                                .into_iter()
+                                .map(|seq| seq.is_some())
+                                .collect::<Vec<_>>(),
+                        )),
+                    );
+                    // Equal ordering values must allow clearing the protected 
field.
+                    let values = vec![Some(50), if first == second { None } 
else { Some(99) }];
+                    let batch = RecordBatch::try_new(
+                        arrow_schema,
+                        vec![
+                            Arc::new(Int32Array::from(vec![1, 1])),
+                            Arc::new(sequence_array),
+                            Arc::new(Int32Array::from(values)),
+                        ],
+                    )
+                    .unwrap();
+                    if separate_commits {
+                        write_commit(&table, &batch.slice(0, 1)).await;
+                        write_commit(&table, &batch.slice(1, 1)).await;
+                    } else {
+                        write_commit(&table, &batch).await;
+                    }
+                    let winner = if direction == "ascending" {
+                        ascending_winner
+                    } else {
+                        descending_winner
+                    };
+                    for projection in [None, Some(["value"].as_slice())] {
+                        let result = read_rows(&table, projection, None).await;
+                        assert_eq!(result.len(), 1);
+                        assert_eq!(result[0].num_rows(), 1);
+                        for field in result[0].schema().fields() {
+                            assert_eq!(
+                                
result[0].column_by_name(field.name()).unwrap().as_ref(),
+                                
batch.column_by_name(field.name()).unwrap().slice(winner, 1).as_ref(),
+                                "direction={direction}, 
separate_commits={separate_commits}, sequences={sequences:?}, 
projection={projection:?}, field={}",
+                                field.name(),
+                            );
+                        }
+                    }
+                }
+            }
+        }
+    }
+
     #[tokio::test]
     async fn 
kv_read_partial_update_sequence_group_aggregation_with_projection() {
         let file_io = test_file_io();
diff --git a/crates/paimon/src/table/kv_file_writer.rs 
b/crates/paimon/src/table/kv_file_writer.rs
index 2a81c33c..35bcb8e7 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -2145,50 +2145,61 @@ mod tests {
 
     #[test]
     fn test_flush_partial_update_sequence_group_delete_preserves_tombstone() {
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new("id", ArrowDataType::Int32, false),
-            ArrowField::new("seq", ArrowDataType::Int64, false),
-            ArrowField::new(VALUE_KIND_FIELD_NAME, ArrowDataType::Int8, false),
-            ArrowField::new("value", ArrowDataType::Int32, true),
-        ]));
-        let batch = RecordBatch::try_new(
-            schema,
-            vec![
-                Arc::new(Int32Array::from(vec![1, 1])),
-                Arc::new(Int64Array::from(vec![10, 20])),
-                Arc::new(Int8Array::from(vec![0, 3])),
-                Arc::new(Int32Array::from(vec![Some(100), Some(100)])),
-            ],
-        )
-        .unwrap();
-        let mut config = test_write_config(MergeEngine::PartialUpdate);
-        config
-            .table_options
-            .insert("fields.seq.sequence-group".into(), "value".into());
-        config.table_options.insert(
-            "partial-update.remove-record-on-sequence-group".into(),
-            "seq".into(),
-        );
-        let writer =
-            
KeyValueFileWriter::new(FileIOBuilder::new("memory").build().unwrap(), config, 
0)
-                .unwrap();
-        let (merged, _) = writer
-            .merge_partial_update_rows(
-                &batch,
-                &Int64Array::from(vec![0, 1]),
-                &UInt32Array::from(vec![0, 1]),
+        for (direction, sequences, expected_kind) in [
+            ("ascending", vec![10, 20], 3),
+            ("descending", vec![20, 10], 3),
+            ("descending", vec![10, 20], 0),
+            ("descending", vec![10, 10], 3),
+        ] {
+            let schema = Arc::new(ArrowSchema::new(vec![
+                ArrowField::new("id", ArrowDataType::Int32, false),
+                ArrowField::new("seq", ArrowDataType::Int64, false),
+                ArrowField::new(VALUE_KIND_FIELD_NAME, ArrowDataType::Int8, 
false),
+                ArrowField::new("value", ArrowDataType::Int32, true),
+            ]));
+            let batch = RecordBatch::try_new(
+                schema,
+                vec![
+                    Arc::new(Int32Array::from(vec![1, 1])),
+                    Arc::new(Int64Array::from(sequences)),
+                    Arc::new(Int8Array::from(vec![0, 3])),
+                    Arc::new(Int32Array::from(vec![Some(100), Some(100)])),
+                ],
             )
             .unwrap();
-        assert_eq!(merged.num_rows(), 1);
-        assert_eq!(
-            merged
-                .column(2)
-                .as_any()
-                .downcast_ref::<Int8Array>()
-                .unwrap()
-                .value(0),
-            3
-        );
+            let mut config = test_write_config(MergeEngine::PartialUpdate);
+            config
+                .table_options
+                .insert("fields.seq.sequence-group".into(), "value".into());
+            config.table_options.insert(
+                "fields.seq.sequence-group.sort-order".into(),
+                direction.into(),
+            );
+            config.table_options.insert(
+                "partial-update.remove-record-on-sequence-group".into(),
+                "seq".into(),
+            );
+            let writer =
+                
KeyValueFileWriter::new(FileIOBuilder::new("memory").build().unwrap(), config, 
0)
+                    .unwrap();
+            let (merged, _) = writer
+                .merge_partial_update_rows(
+                    &batch,
+                    &Int64Array::from(vec![0, 1]),
+                    &UInt32Array::from(vec![0, 1]),
+                )
+                .unwrap();
+            assert_eq!(merged.num_rows(), 1);
+            assert_eq!(
+                merged
+                    .column(2)
+                    .as_any()
+                    .downcast_ref::<Int8Array>()
+                    .unwrap()
+                    .value(0),
+                expected_kind
+            );
+        }
     }
 
     #[test]
@@ -2459,102 +2470,111 @@ mod tests {
 
     #[test]
     fn test_flush_partial_update_sequence_group_and_aggregates() {
-        let mut config = test_write_config(MergeEngine::PartialUpdate);
-        config.table_options.extend([
-            (
-                "fields.version.sequence-group".into(),
-                "price,total,tag".into(),
-            ),
-            ("fields.total.aggregate-function".into(), "sum".into()),
-            ("fields.tag.aggregate-function".into(), "listagg".into()),
-        ]);
-        config.value_fields = vec![
-            DataField::new(0, "id".into(), DataType::Int(IntType::new())),
-            DataField::new(1, "arrival".into(), 
DataType::BigInt(BigIntType::new())),
-            DataField::new(2, "version".into(), 
DataType::BigInt(BigIntType::new())),
-            DataField::new(3, "price".into(), DataType::Int(IntType::new())),
-            DataField::new(4, "total".into(), DataType::Int(IntType::new())),
-            DataField::new(
-                5,
-                "tag".into(),
-                DataType::VarChar(crate::spec::VarCharType::string_type()),
-            ),
-        ];
-        let writer =
-            
KeyValueFileWriter::new(FileIOBuilder::new("memory").build().unwrap(), config, 
0)
-                .unwrap();
-        let schema = Arc::new(ArrowSchema::new(vec![
-            ArrowField::new("id", ArrowDataType::Int32, false),
-            ArrowField::new("arrival", ArrowDataType::Int64, false),
-            ArrowField::new("version", ArrowDataType::Int64, true),
-            ArrowField::new("price", ArrowDataType::Int32, true),
-            ArrowField::new("total", ArrowDataType::Int32, true),
-            ArrowField::new("tag", ArrowDataType::Utf8, true),
-        ]));
-        let batch = RecordBatch::try_new(
-            schema,
-            vec![
-                Arc::new(Int32Array::from(vec![1, 1, 1])),
-                Arc::new(Int64Array::from(vec![1, 2, 3])),
-                Arc::new(Int64Array::from(vec![10, 5, 20])),
-                Arc::new(Int32Array::from(vec![100, 50, 200])),
-                Arc::new(Int32Array::from(vec![3, 7, 11])),
-                Arc::new(StringArray::from(vec!["a", "b", "c"])),
-            ],
-        )
-        .unwrap();
-        let (merged, merged_seq) = writer
-            .merge_partial_update_rows(
-                &batch,
-                &Int64Array::from(vec![100, 101, 102]),
-                &UInt32Array::from(vec![0, 1, 2]),
+        for (direction, version, price, tags) in [
+            ("ascending", 20, 200, "b,a,c"),
+            ("descending", 5, 50, "c,a,b"),
+        ] {
+            let mut config = test_write_config(MergeEngine::PartialUpdate);
+            config.table_options.extend([
+                (
+                    "fields.version.sequence-group".into(),
+                    "price,total,tag".into(),
+                ),
+                (
+                    "fields.version.sequence-group.sort-order".into(),
+                    direction.into(),
+                ),
+                ("fields.total.aggregate-function".into(), "sum".into()),
+                ("fields.tag.aggregate-function".into(), "listagg".into()),
+            ]);
+            config.value_fields = vec![
+                DataField::new(0, "id".into(), DataType::Int(IntType::new())),
+                DataField::new(1, "arrival".into(), 
DataType::BigInt(BigIntType::new())),
+                DataField::new(2, "version".into(), 
DataType::BigInt(BigIntType::new())),
+                DataField::new(3, "price".into(), 
DataType::Int(IntType::new())),
+                DataField::new(4, "total".into(), 
DataType::Int(IntType::new())),
+                DataField::new(
+                    5,
+                    "tag".into(),
+                    DataType::VarChar(crate::spec::VarCharType::string_type()),
+                ),
+            ];
+            let writer =
+                
KeyValueFileWriter::new(FileIOBuilder::new("memory").build().unwrap(), config, 
0)
+                    .unwrap();
+            let schema = Arc::new(ArrowSchema::new(vec![
+                ArrowField::new("id", ArrowDataType::Int32, false),
+                ArrowField::new("arrival", ArrowDataType::Int64, false),
+                ArrowField::new("version", ArrowDataType::Int64, true),
+                ArrowField::new("price", ArrowDataType::Int32, true),
+                ArrowField::new("total", ArrowDataType::Int32, true),
+                ArrowField::new("tag", ArrowDataType::Utf8, true),
+            ]));
+            let batch = RecordBatch::try_new(
+                schema,
+                vec![
+                    Arc::new(Int32Array::from(vec![1, 1, 1])),
+                    Arc::new(Int64Array::from(vec![1, 2, 3])),
+                    Arc::new(Int64Array::from(vec![10, 5, 20])),
+                    Arc::new(Int32Array::from(vec![100, 50, 200])),
+                    Arc::new(Int32Array::from(vec![3, 7, 11])),
+                    Arc::new(StringArray::from(vec!["a", "b", "c"])),
+                ],
             )
             .unwrap();
-        assert_eq!(merged.num_rows(), 1);
-        assert_eq!(
-            merged
-                .column(2)
-                .as_any()
-                .downcast_ref::<Int64Array>()
-                .unwrap()
-                .value(0),
-            20,
-        );
-        assert_eq!(
-            merged
-                .column(3)
-                .as_any()
-                .downcast_ref::<Int32Array>()
-                .unwrap()
-                .value(0),
-            200,
-        );
-        assert_eq!(
-            merged
-                .column(4)
-                .as_any()
-                .downcast_ref::<Int32Array>()
-                .unwrap()
-                .value(0),
-            21,
-        );
-        assert_eq!(
-            merged
-                .column(5)
-                .as_any()
-                .downcast_ref::<StringArray>()
-                .unwrap()
-                .value(0),
-            "b,a,c",
-        );
-        assert_eq!(
-            merged_seq
-                .as_any()
-                .downcast_ref::<Int64Array>()
-                .unwrap()
-                .value(0),
-            102,
-        );
+            let (merged, merged_seq) = writer
+                .merge_partial_update_rows(
+                    &batch,
+                    &Int64Array::from(vec![100, 101, 102]),
+                    &UInt32Array::from(vec![0, 1, 2]),
+                )
+                .unwrap();
+            assert_eq!(merged.num_rows(), 1);
+            assert_eq!(
+                merged
+                    .column(2)
+                    .as_any()
+                    .downcast_ref::<Int64Array>()
+                    .unwrap()
+                    .value(0),
+                version,
+            );
+            assert_eq!(
+                merged
+                    .column(3)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .value(0),
+                price,
+            );
+            assert_eq!(
+                merged
+                    .column(4)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap()
+                    .value(0),
+                21,
+            );
+            assert_eq!(
+                merged
+                    .column(5)
+                    .as_any()
+                    .downcast_ref::<StringArray>()
+                    .unwrap()
+                    .value(0),
+                tags,
+            );
+            assert_eq!(
+                merged_seq
+                    .as_any()
+                    .downcast_ref::<Int64Array>()
+                    .unwrap()
+                    .value(0),
+                102,
+            );
+        }
     }
 
     /// Lock the flush-time merge to the read-side 
`PartialUpdateMergeFunction`.
diff --git a/crates/paimon/src/table/sort_merge.rs 
b/crates/paimon/src/table/sort_merge.rs
index 29320a8a..579ca39b 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -289,6 +289,7 @@ fn merged_metadata_column(
 struct RuntimeSequenceGroup {
     sequence_indices: Vec<usize>,
     protected_indices: Vec<usize>,
+    ascending: bool,
 }
 
 impl PartialUpdateMergeFunction {
@@ -405,6 +406,7 @@ impl PartialUpdateMergeFunction {
                 Ok(RuntimeSequenceGroup {
                     sequence_indices,
                     protected_indices,
+                    ascending: group.ascending,
                 })
             })
             .collect::<crate::Result<Vec<_>>>()?;
@@ -546,7 +548,7 @@ impl PartialUpdateMergeFunction {
                                 row.row_idx,
                                 current_batch,
                                 current_row,
-                                &group.sequence_indices,
+                                group,
                                 batch_buffer,
                                 source_output_col_indices,
                             )?,
@@ -654,7 +656,7 @@ impl PartialUpdateMergeFunction {
                         row.row_idx,
                         current_batch_idx,
                         current_row_idx,
-                        &group.sequence_indices,
+                        group,
                         batch_buffer,
                         source_output_col_indices,
                     )?,
@@ -756,11 +758,11 @@ fn compare_sequence_group_rows(
     incoming_row_idx: usize,
     current_batch_idx: usize,
     current_row_idx: usize,
-    sequence_indices: &[usize],
+    group: &RuntimeSequenceGroup,
     batch_buffer: &[BufferedBatch],
     source_output_col_indices: &[usize],
 ) -> crate::Result<Ordering> {
-    for &output_col_idx in sequence_indices {
+    for &output_col_idx in &group.sequence_indices {
         let incoming = batch_buffer[incoming_batch_idx]
             .column_for_output(output_col_idx, source_output_col_indices);
         let current = batch_buffer[current_batch_idx]
@@ -777,7 +779,15 @@ fn compare_sequence_group_rows(
             message: format!("Failed to compare partial-update sequence-group 
fields: {e}"),
             source: Some(Box::new(e)),
         })?;
-        let ordering = comparator(incoming_row_idx, current_row_idx);
+        let mut ordering = comparator(incoming_row_idx, current_row_idx);
+        // Top-level nulls have lower precedence in either direction. 
Descending
+        // order reverses non-null value comparisons, including nulls inside 
ROWs.
+        if !group.ascending
+            && !incoming.is_null(incoming_row_idx)
+            && !current.is_null(current_row_idx)
+        {
+            ordering = ordering.reverse();
+        }
         if !ordering.is_eq() {
             return Ok(ordering);
         }
@@ -2865,6 +2875,145 @@ mod tests {
         );
     }
 
+    #[tokio::test]
+    async fn test_partial_update_descending_composite_sequence_group() {
+        let output_schema = Arc::new(Schema::new(vec![
+            Field::new("pk", DataType::Int32, false),
+            Field::new("major", DataType::Int32, true),
+            Field::new("minor", DataType::Int32, true),
+            Field::new("value", DataType::Int32, true),
+        ]));
+        let schema = Arc::new(Schema::new(vec![
+            Field::new("pk", DataType::Int32, false),
+            Field::new("_SEQUENCE_NUMBER", DataType::Int64, false),
+            Field::new("_VALUE_KIND", DataType::Int8, false),
+            Field::new("major", DataType::Int32, true),
+            Field::new("minor", DataType::Int32, true),
+            Field::new("value", DataType::Int32, true),
+        ]));
+        let fields = ["pk", "major", "minor", "value"]
+            .iter()
+            .enumerate()
+            .map(|(id, name)| {
+                DataField::new(
+                    id as i32,
+                    (*name).into(),
+                    PaimonDataType::Int(IntType::new()),
+                )
+            })
+            .collect::<Vec<_>>();
+        let options = HashMap::from([
+            ("merge-engine".into(), "partial-update".into()),
+            ("fields.major,minor.sequence-group".into(), "value".into()),
+            (
+                "fields.major,minor.sequence-group.sort-order".into(),
+                "descending".into(),
+            ),
+        ]);
+        // Each case contains two arrival-ordered rows and the winning 
tuple/value.
+        let cases = [
+            // Lexicographic order: each non-null component sorts descending.
+            (
+                [(Some(2), Some(0), Some(10)), (Some(1), Some(9), Some(20))],
+                (Some(1), Some(9), Some(20)),
+            ),
+            (
+                [(Some(1), Some(9), Some(10)), (Some(1), Some(2), Some(20))],
+                (Some(1), Some(2), Some(20)),
+            ),
+            (
+                [(Some(1), Some(2), Some(10)), (Some(1), Some(9), Some(20))],
+                (Some(1), Some(2), Some(10)),
+            ),
+            // Non-null wins over null in either position, regardless of 
direction.
+            (
+                [(None, Some(0), Some(10)), (Some(1), None, Some(20))],
+                (Some(1), None, Some(20)),
+            ),
+            (
+                [(Some(1), None, Some(10)), (None, Some(0), Some(20))],
+                (Some(1), None, Some(10)),
+            ),
+            (
+                [(Some(1), None, Some(10)), (Some(1), Some(9), Some(20))],
+                (Some(1), Some(9), Some(20)),
+            ),
+            (
+                [(Some(1), Some(9), Some(10)), (Some(1), None, Some(20))],
+                (Some(1), Some(9), Some(10)),
+            ),
+            // All-null tuples skip updates; ties can clear protected fields.
+            (
+                [(Some(1), Some(2), Some(10)), (None, None, Some(20))],
+                (Some(1), Some(2), Some(10)),
+            ),
+            (
+                [(Some(1), Some(2), Some(10)), (Some(1), Some(2), None)],
+                (Some(1), Some(2), None),
+            ),
+        ];
+        for (rows, expected) in cases {
+            // Separate streams exercise read-time merging of separate files.
+            let streams = rows
+                .iter()
+                .enumerate()
+                .map(|(index, &(major, minor, value))| {
+                    stream_from_batches(vec![RecordBatch::try_new(
+                        schema.clone(),
+                        vec![
+                            Arc::new(Int32Array::from(vec![1])),
+                            Arc::new(Int64Array::from(vec![index as i64])),
+                            Arc::new(Int8Array::from(vec![0])),
+                            Arc::new(Int32Array::from(vec![major])),
+                            Arc::new(Int32Array::from(vec![minor])),
+                            Arc::new(Int32Array::from(vec![value])),
+                        ],
+                    )
+                    .unwrap()])
+                })
+                .collect();
+            let result = SortMergeReaderBuilder::new(
+                streams,
+                schema.clone(),
+                vec![0],
+                1,
+                2,
+                vec![],
+                vec![3, 4, 5],
+                output_schema.clone(),
+                Box::new(
+                    PartialUpdateMergeFunction::new_with_schema(
+                        &options,
+                        "t",
+                        &fields,
+                        &fields,
+                        &["pk".into()],
+                    )
+                    .unwrap(),
+                ),
+            )
+            .build()
+            .unwrap()
+            .try_collect::<Vec<_>>()
+            .await
+            .unwrap();
+            assert_eq!(result.len(), 1);
+            assert_eq!(result[0].num_rows(), 1);
+            for (index, value) in [expected.0, expected.1, 
expected.2].into_iter().enumerate() {
+                let actual = result[0]
+                    .column(index + 1)
+                    .as_any()
+                    .downcast_ref::<Int32Array>()
+                    .unwrap();
+                assert_eq!(
+                    actual.iter().collect::<Vec<_>>(),
+                    vec![value],
+                    "rows: {rows:?}"
+                );
+            }
+        }
+    }
+
     #[tokio::test]
     async fn 
test_partial_update_composite_sequence_group_skips_empty_sequence() {
         let schema = Arc::new(Schema::new(vec![
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 0e1ebea7..e66918a7 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -2885,13 +2885,28 @@ and an explicit `false` continue to reject these 
retract rows. Once enabled on
 an existing partial-update table, `ignore-delete` cannot be changed back to
 `false`.
 
-Rust can read existing partial-update tables that define
+Rust supports reading and writing partial-update tables that define
 `fields.<sequence-field>.sequence-group=<protected-fields>`. Single and
 composite sequence fields, multiple independent groups, and projected reads are
 supported. Rows whose sequence tuple is entirely null do not update the group;
-an accepted group update can set protected fields to null. Rust table creation
-and writes still reject sequence-group options because write-side group merging
-is not implemented.
+an accepted group update can set protected fields to null.
+
+Set `fields.<sequence-fields>.sequence-group.sort-order` independently for each
+group: `ascending` (the default) retains larger ordering values, while
+`descending` retains smaller ones. Composite sequence fields are compared
+lexicographically in their declared order, using the same direction for every
+component. Non-null ordering values take precedence over null in both
+directions, and equal ordering tuples still replace protected fields, including
+with null. For example, `fields.first_event_time.sequence-group=channel` with
+`fields.first_event_time.sequence-group.sort-order=descending` retains
+`50/referral` from updates `100/organic` and `50/referral`.
+
+Sort-order options require a corresponding sequence group on a primary-key
+table with `merge-engine=partial-update`. Projected reads and column renames
+preserve the direction. Choose it before writing data: changing the direction
+cannot recover values discarded by earlier merges. All readers, writers, and
+compaction jobs must support the option before using `descending`; older
+clients may ignore it.
 
 Existing partial-update tables may also configure
 `fields.<field>.aggregate-function` or
@@ -2900,13 +2915,10 @@ set as the basic aggregation engine: `sum`, `product`, 
`min`, `max`,
 `last_value`, `first_value`, `last_non_null_value`,
 `first_non_null_value`, `bool_and`, `bool_or`, and `listagg`.
 `last_non_null_value` may be used without a sequence group; other functions
-require the target field to be protected by a sequence group. When an older
-group sequence arrives after a newer one, order-sensitive functions use Java
+require the target field to be protected by a sequence group. When a
+lower-precedence group sequence arrives, order-sensitive functions use Java
 compatible reversed aggregation. Unknown functions and incompatible
-function/type combinations fail closed. Rust table creation and writes still
-reject partial-update aggregation options.
-
-Partial-update remove-record options and retract semantics are not supported.
+function/type combinations fail closed.
 
 Rust can read fully materialized compacted files from deletion-vector-enabled
 partial-update and aggregation tables. Every split must be raw-convertible,

Reply via email to