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 5f9f1843 partial_update: support sequence-group reads (#584)
5f9f1843 is described below
commit 5f9f1843fb80af8e45c616731876cbd5592c44b6
Author: shyjsarah <[email protected]>
AuthorDate: Wed Jul 22 22:47:22 2026 +0800
partial_update: support sequence-group reads (#584)
---
crates/paimon/src/spec/partial_update.rs | 351 +++++++++++++++++++++++-
crates/paimon/src/table/kv_file_reader.rs | 183 ++++++++++++-
crates/paimon/src/table/kv_file_writer.rs | 2 +-
crates/paimon/src/table/sort_merge.rs | 435 +++++++++++++++++++++++++++++-
docs/src/sql.md | 12 +-
5 files changed, 960 insertions(+), 23 deletions(-)
diff --git a/crates/paimon/src/spec/partial_update.rs
b/crates/paimon/src/spec/partial_update.rs
index 07a57d73..f1c84e45 100644
--- a/crates/paimon/src/spec/partial_update.rs
+++ b/crates/paimon/src/spec/partial_update.rs
@@ -15,7 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
+
+use crate::spec::DataField;
const MERGE_ENGINE_OPTION: &str = "merge-engine";
const PARTIAL_UPDATE_ENGINE: &str = "partial-update";
@@ -31,16 +33,32 @@ const FIELDS_PREFIX: &str = "fields.";
const SEQUENCE_GROUP_SUFFIX: &str = ".sequence-group";
const AGGREGATION_FUNCTION_SUFFIX: &str = ".aggregate-function";
-/// Minimal partial-update mode recognized by the current Rust implementation.
+/// Partial-update mode recognized by the current Rust implementation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PartialUpdateMode {
Basic,
+ SequenceGroup,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct SequenceGroup {
+ pub(crate) sequence_fields: Vec<String>,
+ pub(crate) protected_fields: Vec<String>,
+}
+
+impl SequenceGroup {
+ fn option_key(&self) -> String {
+ format!(
+ "{FIELDS_PREFIX}{}{SEQUENCE_GROUP_SUFFIX}",
+ self.sequence_fields.join(",")
+ )
+ }
}
/// Partial-update-specific option inspection and validation.
///
-/// PR1 only recognizes the basic mode: `merge-engine=partial-update` on a PK
-/// table without delete, sequence-group, or aggregation controls.
+/// Reads support basic partial update and sequence groups. Table creation and
+/// writes remain restricted to basic partial update.
#[derive(Debug, Clone, Copy)]
pub(crate) struct PartialUpdateConfig<'a> {
options: &'a HashMap<String, String>,
@@ -72,7 +90,7 @@ impl<'a> PartialUpdateConfig<'a> {
}
}
- pub(crate) fn validate_runtime_mode(
+ pub(crate) fn validate_write_mode(
&self,
has_primary_keys: bool,
table_name: &str,
@@ -88,6 +106,143 @@ impl<'a> PartialUpdateConfig<'a> {
}
}
+ pub(crate) fn validate_read_mode(
+ &self,
+ has_primary_keys: bool,
+ table_name: &str,
+ ) -> crate::Result<Option<PartialUpdateMode>> {
+ if !has_primary_keys || !self.is_enabled() {
+ return Ok(None);
+ }
+
+ let unsupported_options = self.read_unsupported_option_keys();
+ if !unsupported_options.is_empty() {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "Table '{table_name}' uses merge-engine=partial-update
options not supported by this build: {}",
+ unsupported_options.join(", ")
+ ),
+ });
+ }
+
+ Ok(Some(if self.sequence_groups()?.is_empty() {
+ PartialUpdateMode::Basic
+ } else {
+ PartialUpdateMode::SequenceGroup
+ }))
+ }
+
+ pub(crate) fn sequence_groups(&self) -> crate::Result<Vec<SequenceGroup>> {
+ let mut options: Vec<(&String, &String)> = self
+ .options
+ .iter()
+ .filter(|(key, _)| is_fields_option_with_suffix(key,
SEQUENCE_GROUP_SUFFIX))
+ .collect();
+ options.sort_by_key(|(key, _)| *key);
+
+ options
+ .into_iter()
+ .map(|(key, value)| {
+ let sequence_fields = key
+ .strip_prefix(FIELDS_PREFIX)
+ .and_then(|key| key.strip_suffix(SEQUENCE_GROUP_SUFFIX))
+ .ok_or_else(|| crate::Error::ConfigInvalid {
+ message: format!(
+ "Invalid partial-update sequence-group option
'{key}={value}'"
+ ),
+ })
+ .and_then(|fields| parse_field_list(fields, key, value))?;
+ let protected_fields = parse_field_list(value, key, value)?;
+ Ok(SequenceGroup {
+ sequence_fields,
+ protected_fields,
+ })
+ })
+ .collect()
+ }
+
+ pub(crate) fn validated_sequence_groups(
+ &self,
+ fields: &[DataField],
+ primary_keys: &[String],
+ ) -> crate::Result<Vec<SequenceGroup>> {
+ let groups = self.sequence_groups()?;
+ let field_names: HashSet<&str> =
fields.iter().map(DataField::name).collect();
+ let primary_keys: HashSet<&str> =
primary_keys.iter().map(String::as_str).collect();
+ let mut protected_field_owners: HashMap<String, String> =
HashMap::new();
+
+ for group in &groups {
+ let option_key = group.option_key();
+ for field in group
+ .sequence_fields
+ .iter()
+ .chain(group.protected_fields.iter())
+ {
+ if !field_names.contains(field.as_str()) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Field '{field}' referenced by partial-update
sequence-group \
+ option '{option_key}' does not exist in the table
schema"
+ ),
+ });
+ }
+ if primary_keys.contains(field.as_str()) {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "The sequence-group '{option_key}' contains
primary key field \
+ '{field}', which is not allowed. Primary key
columns cannot be put \
+ in sequence-group."
+ ),
+ });
+ }
+ }
+ for field in &group.protected_fields {
+ if let Some(previous_option) =
+ protected_field_owners.insert(field.clone(),
option_key.clone())
+ {
+ return Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Field '{field}' is protected by multiple sequence
groups: \
+ '{previous_option}' and '{option_key}'"
+ ),
+ });
+ }
+ }
+ }
+
+ Ok(groups)
+ }
+
+ pub(crate) fn required_sequence_fields(
+ &self,
+ fields: &[DataField],
+ primary_keys: &[String],
+ projected_fields: &[String],
+ ) -> crate::Result<Vec<String>> {
+ let groups = self.validated_sequence_groups(fields, primary_keys)?;
+ let projected: HashSet<&str> =
projected_fields.iter().map(String::as_str).collect();
+ let mut required = Vec::new();
+ let mut seen = HashSet::new();
+
+ for group in groups {
+ let group_is_projected = group
+ .sequence_fields
+ .iter()
+ .chain(group.protected_fields.iter())
+ .any(|field| projected.contains(field.as_str()));
+ if !group_is_projected {
+ continue;
+ }
+ for field in group.sequence_fields {
+ if seen.insert(field.clone()) {
+ required.push(field);
+ }
+ }
+ }
+
+ Ok(required)
+ }
+
fn validated_mode(
&self,
has_primary_keys: bool,
@@ -114,6 +269,20 @@ impl<'a> PartialUpdateConfig<'a> {
keys.sort();
keys
}
+
+ fn read_unsupported_option_keys(&self) -> Vec<String> {
+ let mut keys: Vec<String> = self
+ .options
+ .keys()
+ .filter(|key| {
+ is_unsupported_partial_update_option(key)
+ && !is_fields_option_with_suffix(key,
SEQUENCE_GROUP_SUFFIX)
+ })
+ .cloned()
+ .collect();
+ keys.sort();
+ keys
+ }
}
fn is_unsupported_partial_update_option(key: &str) -> bool {
@@ -131,9 +300,33 @@ fn is_fields_option_with_suffix(key: &str, suffix: &str)
-> bool {
key.starts_with(FIELDS_PREFIX) && key.ends_with(suffix)
}
+fn parse_field_list(
+ value: &str,
+ option_key: &str,
+ option_value: &str,
+) -> crate::Result<Vec<String>> {
+ value
+ .split(',')
+ .map(str::trim)
+ .map(|field| {
+ if field.is_empty() {
+ Err(crate::Error::ConfigInvalid {
+ message: format!(
+ "Invalid partial-update sequence-group option \
+ '{option_key}={option_value}': empty field name"
+ ),
+ })
+ } else {
+ Ok(field.to_string())
+ }
+ })
+ .collect()
+}
+
#[cfg(test)]
mod tests {
use super::*;
+ use crate::spec::{DataField, DataType, IntType};
fn partial_update_options(extra: &[(&str, &str)]) -> HashMap<String,
String> {
let mut options = HashMap::from([(
@@ -216,15 +409,159 @@ mod tests {
}
#[test]
- fn test_validate_runtime_mode_rejects_unsupported_partial_update_options()
{
+ fn test_validate_write_mode_rejects_unsupported_partial_update_options() {
let options =
partial_update_options(&[("fields.price.aggregate-function",
"last_non_null")]);
let config = PartialUpdateConfig::new(&options);
- let err = config.validate_runtime_mode(true, "default.t").unwrap_err();
+ let err = config.validate_write_mode(true, "default.t").unwrap_err();
assert!(
matches!(err, crate::Error::Unsupported { ref message } if
message.contains("fields.price.aggregate-function")),
"expected runtime rejection to mention the unsupported option, got
{err:?}"
);
}
+
+ #[test]
+ fn test_validate_read_mode_accepts_sequence_group() {
+ let options =
+ partial_update_options(&[("fields.updated_at.sequence-group",
"price,quantity")]);
+ let config = PartialUpdateConfig::new(&options);
+
+ assert_eq!(
+ config.validate_read_mode(true, "default.t").unwrap(),
+ Some(PartialUpdateMode::SequenceGroup)
+ );
+ }
+
+ #[test]
+ fn test_parse_sequence_groups() {
+ let options = partial_update_options(&[
+ (
+ "fields.event_time,source_order.sequence-group",
+ "price,quantity",
+ ),
+ ("fields.profile_version.sequence-group", "name,address"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+
+ assert_eq!(
+ config.sequence_groups().unwrap(),
+ vec![
+ SequenceGroup {
+ sequence_fields: vec!["event_time".to_string(),
"source_order".to_string()],
+ protected_fields: vec!["price".to_string(),
"quantity".to_string()],
+ },
+ SequenceGroup {
+ sequence_fields: vec!["profile_version".to_string()],
+ protected_fields: vec!["name".to_string(),
"address".to_string()],
+ },
+ ]
+ );
+ }
+
+ #[test]
+ fn test_parse_sequence_groups_rejects_empty_field_names() {
+ for (key, value) in [
+ ("fields.version,,source.sequence-group", "price"),
+ ("fields.version.sequence-group", "price,,quantity"),
+ ] {
+ let options = partial_update_options(&[(key, value)]);
+ let config = PartialUpdateConfig::new(&options);
+
+ let err = config.sequence_groups().unwrap_err();
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains(key) && message.contains("empty field
name")),
+ "expected malformed field list to be rejected, got {err:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn test_validate_sequence_groups_rejects_primary_key() {
+ let options =
partial_update_options(&[("fields.version.sequence-group", "id,price")]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_sequence_groups(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("fields.version.sequence-group")
+ && message.contains("primary key field 'id'")),
+ "expected primary-key conflict, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_validate_sequence_groups_rejects_duplicate_protected_field() {
+ let options = partial_update_options(&[
+ ("fields.version_a.sequence-group", "price"),
+ ("fields.version_b.sequence-group", "price"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version_a".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "version_b".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(3, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ let err = config
+ .validated_sequence_groups(&fields, &["id".to_string()])
+ .unwrap_err();
+
+ assert!(
+ matches!(err, crate::Error::ConfigInvalid { ref message }
+ if message.contains("price") && message.contains("multiple
sequence groups")),
+ "expected duplicate protected-field error, got {err:?}"
+ );
+ }
+
+ #[test]
+ fn test_required_sequence_fields_for_projection() {
+ let options = partial_update_options(&[
+ (
+ "fields.event_time,source_order.sequence-group",
+ "price,quantity",
+ ),
+ ("fields.profile_version.sequence-group", "name,address"),
+ ]);
+ let config = PartialUpdateConfig::new(&options);
+ let fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "event_time".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "source_order".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(3, "price".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(4, "quantity".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(
+ 5,
+ "profile_version".to_string(),
+ DataType::Int(IntType::new()),
+ ),
+ DataField::new(6, "name".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(7, "address".to_string(),
DataType::Int(IntType::new())),
+ ];
+
+ assert_eq!(
+ config
+ .required_sequence_fields(
+ &fields,
+ &["id".to_string()],
+ &["price".to_string(), "name".to_string()],
+ )
+ .unwrap(),
+ vec![
+ "event_time".to_string(),
+ "source_order".to_string(),
+ "profile_version".to_string(),
+ ]
+ );
+ }
}
diff --git a/crates/paimon/src/table/kv_file_reader.rs
b/crates/paimon/src/table/kv_file_reader.rs
index db97e66b..bb36e369 100644
--- a/crates/paimon/src/table/kv_file_reader.rs
+++ b/crates/paimon/src/table/kv_file_reader.rs
@@ -33,8 +33,8 @@ use super::sort_merge::{
use crate::arrow::build_target_arrow_schema;
use crate::io::FileIO;
use crate::spec::{
- BigIntType, DataField, DataType as PaimonDataType, MergeEngine, Predicate,
TinyIntType,
- SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME, VALUE_KIND_FIELD_ID,
+ BigIntType, DataField, DataType as PaimonDataType, MergeEngine,
PartialUpdateConfig, Predicate,
+ TinyIntType, SEQUENCE_NUMBER_FIELD_ID, SEQUENCE_NUMBER_FIELD_NAME,
VALUE_KIND_FIELD_ID,
VALUE_KIND_FIELD_NAME,
};
use crate::table::schema_manager::SchemaManager;
@@ -107,6 +107,45 @@ pub(super) fn retain_primary_key_conjuncts(
.collect()
}
+fn widen_partial_update_sequence_group_fields(
+ merge_engine: MergeEngine,
+ table_options: &HashMap<String, String>,
+ table_fields: &[DataField],
+ primary_keys: &[String],
+ mut user_fields: Vec<DataField>,
+) -> crate::Result<Vec<DataField>> {
+ if merge_engine != MergeEngine::PartialUpdate {
+ return Ok(user_fields);
+ }
+
+ let projected_fields = user_fields
+ .iter()
+ .map(|field| field.name().to_string())
+ .collect::<Vec<_>>();
+ let required_fields =
PartialUpdateConfig::new(table_options).required_sequence_fields(
+ table_fields,
+ primary_keys,
+ &projected_fields,
+ )?;
+ for field_name in required_fields {
+ if user_fields.iter().any(|field| field.name() == field_name) {
+ continue;
+ }
+ let field = table_fields
+ .iter()
+ .find(|field| field.name() == field_name)
+ .cloned()
+ .ok_or_else(|| Error::UnexpectedError {
+ message: format!(
+ "Partial-update sequence field '{field_name}' not found in
table schema"
+ ),
+ source: None,
+ })?;
+ user_fields.push(field);
+ }
+ Ok(user_fields)
+}
+
impl KeyValueFileReader {
pub(crate) fn new(file_io: FileIO, config: KeyValueReadConfig) -> Self {
let pushdown_predicates = retain_primary_key_conjuncts(
@@ -136,16 +175,22 @@ impl KeyValueFileReader {
merge_engine: MergeEngine,
table_options: &HashMap<String, String>,
table_name: &str,
+ table_fields: &[DataField],
merge_output_fields: &[DataField],
primary_keys: &[String],
sequence_fields: &[String],
) -> crate::Result<Box<dyn super::sort_merge::MergeFunction>> {
match merge_engine {
MergeEngine::Deduplicate => Ok(Box::new(DeduplicateMergeFunction)),
- MergeEngine::PartialUpdate =>
Ok(Box::new(PartialUpdateMergeFunction::new(
- table_options,
- table_name,
- )?)),
+ MergeEngine::PartialUpdate => Ok(Box::new(
+ PartialUpdateMergeFunction::new_with_schema(
+ table_options,
+ table_name,
+ table_fields,
+ merge_output_fields,
+ primary_keys,
+ )?,
+ )),
MergeEngine::FirstRow => Err(Error::Unsupported {
message: "KeyValueFileReader does not support
merge-engine=first-row; first-row reads should use the non-KV path".to_string(),
}),
@@ -242,6 +287,13 @@ impl KeyValueFileReader {
&user_fields,
residual_file_predicates.as_ref(),
);
+ let user_fields = widen_partial_update_sequence_group_fields(
+ self.config.merge_engine,
+ &self.config.table_options,
+ &self.config.table_fields,
+ &self.config.primary_keys,
+ user_fields,
+ )?;
// Internal read type: [_SEQ, _VK, user_fields...]
let mut internal_read_type: Vec<DataField> = Vec::new();
@@ -413,6 +465,7 @@ impl KeyValueFileReader {
merge_engine,
&table_options,
&table_name,
+ &table_fields,
&merge_output_fields,
&primary_keys,
&sequence_fields,
@@ -678,6 +731,36 @@ mod tests {
assert!(matches!(&kept[0], Predicate::AlwaysTrue));
}
+ #[test]
+ fn widen_partial_update_projection_with_sequence_fields() {
+ let table_fields = vec![
+ DataField::new(0, "id".to_string(), DataType::Int(IntType::new())),
+ DataField::new(1, "version".to_string(),
DataType::Int(IntType::new())),
+ DataField::new(2, "price".to_string(),
DataType::Int(IntType::new())),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.version.sequence-group".to_string(),
+ "price".to_string(),
+ ),
+ ]);
+
+ let widened = widen_partial_update_sequence_group_fields(
+ MergeEngine::PartialUpdate,
+ &options,
+ &table_fields,
+ &["id".to_string()],
+ vec![table_fields[2].clone()],
+ )
+ .unwrap();
+
+ assert_eq!(
+ widened.iter().map(DataField::name).collect::<Vec<_>>(),
+ vec!["price", "version"]
+ );
+ }
+
#[tokio::test]
async fn
kv_input_decode_honors_read_batch_size_without_changing_merge_batching() {
let file_io = test_file_io();
@@ -1147,6 +1230,94 @@ mod tests {
assert_eq!(int_column(&batches, "b"), vec![7]);
}
+ #[tokio::test]
+ async fn kv_read_partial_update_sequence_groups_with_projection() {
+ let file_io = test_file_io();
+ let table_path = "memory:/kv_partial_update_sequence_groups";
+ setup_dirs(&file_io, table_path).await;
+
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("seq_a", DataType::Int(IntType::new()))
+ .column("value_a", DataType::Int(IntType::new()))
+ .column("seq_b", DataType::Int(IntType::new()))
+ .column("value_b", DataType::Int(IntType::new()))
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("merge-engine", "partial-update")
+ .build()
+ .unwrap();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "kv_partial_update_sequence_groups_t"),
+ table_path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ let arrow_schema = Arc::new(ArrowSchema::new(vec![
+ ArrowField::new("id", ArrowDataType::Int32, false),
+ ArrowField::new("seq_a", ArrowDataType::Int32, true),
+ ArrowField::new("value_a", ArrowDataType::Int32, true),
+ ArrowField::new("seq_b", ArrowDataType::Int32, true),
+ ArrowField::new("value_b", ArrowDataType::Int32, true),
+ ]));
+ let make = |seq_a, value_a, seq_b, value_b| {
+ RecordBatch::try_new(
+ arrow_schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int32Array::from(vec![seq_a])),
+ Arc::new(Int32Array::from(vec![value_a])),
+ Arc::new(Int32Array::from(vec![seq_b])),
+ Arc::new(Int32Array::from(vec![value_b])),
+ ],
+ )
+ .unwrap()
+ };
+
+ write_commit(&table, &make(10, 100, 10, 1000)).await;
+ write_commit(&table, &make(9, 200, 11, 2000)).await;
+
+ let sequence_group_schema =
table.schema().copy_with_options(HashMap::from([
+ (
+ "fields.seq_a.sequence-group".to_string(),
+ "value_a".to_string(),
+ ),
+ (
+ "fields.seq_b.sequence-group".to_string(),
+ "value_b".to_string(),
+ ),
+ ]));
+ let sequence_group_table = Table::new(
+ file_io,
+ Identifier::new("default", "kv_partial_update_sequence_groups_t"),
+ table_path.to_string(),
+ sequence_group_schema,
+ None,
+ );
+
+ let batches = read_rows(
+ &sequence_group_table,
+ Some(&["id", "value_a", "value_b"]),
+ None,
+ )
+ .await;
+
+ assert_eq!(int_column(&batches, "value_a"), vec![100]);
+ assert_eq!(int_column(&batches, "value_b"), vec![2000]);
+ for batch in batches {
+ assert_eq!(
+ batch
+ .schema()
+ .fields()
+ .iter()
+ .map(|field| field.name().as_str())
+ .collect::<Vec<_>>(),
+ vec!["id", "value_a", "value_b"]
+ );
+ }
+ }
+
/// An AlwaysFalse filter on a PK table must return nothing, end to end.
/// Two layers enforce it: scan-side stats pruning treats AlwaysFalse as
/// prune-everything (plans no files), and the post-merge residual masks
diff --git a/crates/paimon/src/table/kv_file_writer.rs
b/crates/paimon/src/table/kv_file_writer.rs
index 86873e8a..b5e94f54 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -110,7 +110,7 @@ impl KeyValueFileWriter {
&& CoreOptions::new(&config.table_options).ignore_delete();
if config.merge_engine == MergeEngine::PartialUpdate {
PartialUpdateConfig::new(&config.table_options)
- .validate_runtime_mode(true, &config.table_name)?;
+ .validate_write_mode(true, &config.table_name)?;
if config.deletion_vectors_enabled {
return Err(crate::Error::Unsupported {
diff --git a/crates/paimon/src/table/sort_merge.rs
b/crates/paimon/src/table/sort_merge.rs
index abbb6513..4a6d5835 100644
--- a/crates/paimon/src/table/sort_merge.rs
+++ b/crates/paimon/src/table/sort_merge.rs
@@ -31,8 +31,9 @@ use crate::table::aggregator::{new_aggregator,
FieldAggregator};
use crate::table::ArrowRecordBatchStream;
use crate::Error;
use arrow_array::{new_null_array, ArrayRef, Int64Array, Int8Array,
RecordBatch};
+use arrow_ord::ord::make_comparator;
use arrow_row::{RowConverter, Rows, SortField};
-use arrow_schema::SchemaRef;
+use arrow_schema::{SchemaRef, SortOptions};
use arrow_select::interleave::interleave;
use async_stream::try_stream;
use futures::StreamExt;
@@ -186,19 +187,99 @@ impl MergeFunction for DeduplicateMergeFunction {
///
/// DELETE / UPDATE_BEFORE rows are ignored when `ignore-delete=true` and
/// treated as unsupported otherwise.
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone)]
pub(crate) struct PartialUpdateMergeFunction {
ignore_delete: bool,
+ sequence_groups: Vec<RuntimeSequenceGroup>,
+ grouped_fields: HashSet<usize>,
+}
+
+#[derive(Debug, Clone)]
+struct RuntimeSequenceGroup {
+ sequence_indices: Vec<usize>,
+ protected_indices: Vec<usize>,
}
impl PartialUpdateMergeFunction {
+ #[cfg(test)]
pub(crate) fn new(
table_options: &HashMap<String, String>,
table_name: &str,
) -> crate::Result<Self> {
- PartialUpdateConfig::new(table_options).validate_runtime_mode(true,
table_name)?;
+ PartialUpdateConfig::new(table_options).validate_write_mode(true,
table_name)?;
Ok(Self {
ignore_delete: CoreOptions::new(table_options).ignore_delete(),
+ sequence_groups: Vec::new(),
+ grouped_fields: HashSet::new(),
+ })
+ }
+
+ pub(crate) fn new_with_schema(
+ table_options: &HashMap<String, String>,
+ table_name: &str,
+ table_fields: &[DataField],
+ output_fields: &[DataField],
+ primary_keys: &[String],
+ ) -> crate::Result<Self> {
+ let config = PartialUpdateConfig::new(table_options);
+ config.validate_read_mode(true, table_name)?;
+ let groups = config.validated_sequence_groups(table_fields,
primary_keys)?;
+ let field_indices: HashMap<&str, usize> = output_fields
+ .iter()
+ .enumerate()
+ .map(|(index, field)| (field.name(), index))
+ .collect();
+ let sequence_groups = groups
+ .into_iter()
+ .filter(|group| {
+ group
+ .sequence_fields
+ .iter()
+ .chain(group.protected_fields.iter())
+ .any(|field| field_indices.contains_key(field.as_str()))
+ })
+ .map(|group| {
+ let sequence_indices = group
+ .sequence_fields
+ .iter()
+ .map(|field| {
+
field_indices.get(field.as_str()).copied().ok_or_else(|| {
+ Error::UnexpectedError {
+ message: format!(
+ "Projected partial-update sequence group
is missing \
+ sequence field '{field}'"
+ ),
+ source: None,
+ }
+ })
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ let protected_indices = group
+ .protected_fields
+ .iter()
+ .filter_map(|field|
field_indices.get(field.as_str()).copied())
+ .collect();
+ Ok(RuntimeSequenceGroup {
+ sequence_indices,
+ protected_indices,
+ })
+ })
+ .collect::<crate::Result<Vec<_>>>()?;
+ let grouped_fields = sequence_groups
+ .iter()
+ .flat_map(|group| {
+ group
+ .sequence_indices
+ .iter()
+ .chain(group.protected_indices.iter())
+ })
+ .copied()
+ .collect();
+
+ Ok(Self {
+ ignore_delete: CoreOptions::new(table_options).ignore_delete(),
+ sequence_groups,
+ grouped_fields,
})
}
}
@@ -224,8 +305,10 @@ impl MergeFunction for PartialUpdateMergeFunction {
.then_with(|| lhs_idx.cmp(&rhs_idx))
});
- let mut latest_non_null_by_col: Vec<Option<(usize, usize)>> =
+ let mut selected_by_col: Vec<Option<(usize, usize)>> =
vec![None; output_schema.fields().len()];
+ let mut group_sequence_rows: Vec<Option<(usize, usize)>> =
+ vec![None; self.sequence_groups.len()];
let mut saw_add = false;
for row_idx in ordered_row_indices {
@@ -240,11 +323,50 @@ impl MergeFunction for PartialUpdateMergeFunction {
}
saw_add = true;
- for (output_col_idx, latest_non_null) in
latest_non_null_by_col.iter_mut().enumerate() {
+ for (output_col_idx, selected) in
selected_by_col.iter_mut().enumerate() {
+ if self.grouped_fields.contains(&output_col_idx) {
+ continue;
+ }
let source_array = batch_buffer[row.batch_idx]
.column_for_output(output_col_idx,
source_output_col_indices);
if !source_array.is_null(row.row_idx) {
- *latest_non_null = Some((row.batch_idx, row.row_idx));
+ *selected = Some((row.batch_idx, row.row_idx));
+ }
+ }
+
+ for (group_idx, group) in self.sequence_groups.iter().enumerate() {
+ let sequence_is_empty =
group.sequence_indices.iter().all(|&output_col_idx| {
+ batch_buffer[row.batch_idx]
+ .column_for_output(output_col_idx,
source_output_col_indices)
+ .is_null(row.row_idx)
+ });
+ if sequence_is_empty {
+ continue;
+ }
+
+ let should_advance = match group_sequence_rows[group_idx] {
+ None => true,
+ Some((current_batch_idx, current_row_idx)) =>
compare_sequence_group_rows(
+ row.batch_idx,
+ row.row_idx,
+ current_batch_idx,
+ current_row_idx,
+ &group.sequence_indices,
+ batch_buffer,
+ source_output_col_indices,
+ )?
+ .is_ge(),
+ };
+ if !should_advance {
+ continue;
+ }
+
+ group_sequence_rows[group_idx] = Some((row.batch_idx,
row.row_idx));
+ for &output_col_idx in &group.sequence_indices {
+ selected_by_col[output_col_idx] = Some((row.batch_idx,
row.row_idx));
+ }
+ for &output_col_idx in &group.protected_indices {
+ selected_by_col[output_col_idx] = Some((row.batch_idx,
row.row_idx));
}
}
}
@@ -258,7 +380,7 @@ impl MergeFunction for PartialUpdateMergeFunction {
.iter()
.enumerate()
.map(|(output_col_idx, field)| {
- Ok(match latest_non_null_by_col[output_col_idx] {
+ Ok(match selected_by_col[output_col_idx] {
Some((batch_idx, row_idx)) => batch_buffer[batch_idx]
.column_for_output(output_col_idx,
source_output_col_indices)
.slice(row_idx, 1),
@@ -289,6 +411,40 @@ impl MergeFunction for PartialUpdateMergeFunction {
}
}
+fn compare_sequence_group_rows(
+ incoming_batch_idx: usize,
+ incoming_row_idx: usize,
+ current_batch_idx: usize,
+ current_row_idx: usize,
+ sequence_indices: &[usize],
+ batch_buffer: &[BufferedBatch],
+ source_output_col_indices: &[usize],
+) -> crate::Result<Ordering> {
+ for &output_col_idx in 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]
+ .column_for_output(output_col_idx, source_output_col_indices);
+ let comparator = make_comparator(
+ incoming,
+ current,
+ SortOptions {
+ descending: false,
+ nulls_first: true,
+ },
+ )
+ .map_err(|e| Error::DataInvalid {
+ 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);
+ if !ordering.is_eq() {
+ return Ok(ordering);
+ }
+ }
+ Ok(Ordering::Equal)
+}
+
// ---------------------------------------------------------------------------
// AggregateMergeFunction
// ---------------------------------------------------------------------------
@@ -1882,6 +2038,271 @@ mod tests {
);
}
+ #[tokio::test]
+ async fn test_partial_update_sequence_groups_advance_independently() {
+ 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("seq_a", DataType::Int32, true),
+ Field::new("value_a", DataType::Int32, true),
+ Field::new("seq_b", DataType::Int32, true),
+ Field::new("value_b", DataType::Int32, true),
+ ]));
+ let output_schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("seq_a", DataType::Int32, true),
+ Field::new("value_a", DataType::Int32, true),
+ Field::new("seq_b", DataType::Int32, true),
+ Field::new("value_b", DataType::Int32, true),
+ ]));
+ let output_fields = vec![
+ DataField::new(0, "pk".into(),
crate::spec::DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "seq_a".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 2,
+ "value_a".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 3,
+ "seq_b".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 4,
+ "value_b".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.seq_a.sequence-group".to_string(),
+ "value_a".to_string(),
+ ),
+ (
+ "fields.seq_b.sequence-group".to_string(),
+ "value_b".to_string(),
+ ),
+ ]);
+ let old = stream_from_batches(vec![RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![1])),
+ Arc::new(Int8Array::from(vec![0])),
+ Arc::new(Int32Array::from(vec![10])),
+ Arc::new(Int32Array::from(vec![100])),
+ Arc::new(Int32Array::from(vec![10])),
+ Arc::new(Int32Array::from(vec![1000])),
+ ],
+ )
+ .unwrap()]);
+ let mixed = stream_from_batches(vec![RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1])),
+ Arc::new(Int64Array::from(vec![2])),
+ Arc::new(Int8Array::from(vec![0])),
+ Arc::new(Int32Array::from(vec![9])),
+ Arc::new(Int32Array::from(vec![200])),
+ Arc::new(Int32Array::from(vec![11])),
+ Arc::new(Int32Array::from(vec![2000])),
+ ],
+ )
+ .unwrap()]);
+
+ let result = SortMergeReaderBuilder::new(
+ vec![old, mixed],
+ schema,
+ vec![0],
+ 1,
+ 2,
+ vec![],
+ vec![3, 4, 5, 6],
+ output_schema,
+ Box::new(
+ PartialUpdateMergeFunction::new_with_schema(
+ &options,
+ "test_table",
+ &output_fields,
+ &output_fields,
+ &["pk".to_string()],
+ )
+ .unwrap(),
+ ),
+ )
+ .build()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ assert_eq!(result.len(), 1);
+ let batch = &result[0];
+ assert_eq!(
+ batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 10
+ );
+ assert_eq!(
+ batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 100
+ );
+ assert_eq!(
+ batch
+ .column(3)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 11
+ );
+ assert_eq!(
+ batch
+ .column(4)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 2000
+ );
+ }
+
+ #[tokio::test]
+ async fn
test_partial_update_composite_sequence_group_skips_empty_sequence() {
+ 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("seq_major", DataType::Int32, true),
+ Field::new("seq_minor", DataType::Int32, true),
+ Field::new("value", DataType::Int32, true),
+ ]));
+ let output_schema = Arc::new(Schema::new(vec![
+ Field::new("pk", DataType::Int32, false),
+ Field::new("seq_major", DataType::Int32, true),
+ Field::new("seq_minor", DataType::Int32, true),
+ Field::new("value", DataType::Int32, true),
+ ]));
+ let output_fields = vec![
+ DataField::new(0, "pk".into(),
crate::spec::DataType::Int(IntType::new())),
+ DataField::new(
+ 1,
+ "seq_major".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 2,
+ "seq_minor".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ DataField::new(
+ 3,
+ "value".into(),
+ crate::spec::DataType::Int(IntType::new()),
+ ),
+ ];
+ let options = HashMap::from([
+ ("merge-engine".to_string(), "partial-update".to_string()),
+ (
+ "fields.seq_major,seq_minor.sequence-group".to_string(),
+ "value".to_string(),
+ ),
+ ]);
+ let stream = stream_from_batches(vec![RecordBatch::try_new(
+ schema.clone(),
+ vec![
+ Arc::new(Int32Array::from(vec![1, 1, 1, 1, 1])),
+ Arc::new(Int64Array::from(vec![1, 2, 3, 4, 5])),
+ Arc::new(Int8Array::from(vec![0, 0, 0, 0, 0])),
+ Arc::new(Int32Array::from(vec![
+ Some(1),
+ Some(2),
+ None,
+ Some(2),
+ Some(3),
+ ])),
+ Arc::new(Int32Array::from(vec![
+ Some(2),
+ Some(1),
+ None,
+ None,
+ Some(0),
+ ])),
+ Arc::new(Int32Array::from(vec![
+ Some(100),
+ Some(200),
+ Some(300),
+ Some(400),
+ None,
+ ])),
+ ],
+ )
+ .unwrap()]);
+
+ let result = SortMergeReaderBuilder::new(
+ vec![stream],
+ schema,
+ vec![0],
+ 1,
+ 2,
+ vec![],
+ vec![3, 4, 5],
+ output_schema,
+ Box::new(
+ PartialUpdateMergeFunction::new_with_schema(
+ &options,
+ "test_table",
+ &output_fields,
+ &output_fields,
+ &["pk".to_string()],
+ )
+ .unwrap(),
+ ),
+ )
+ .build()
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+
+ let batch = &result[0];
+ assert_eq!(
+ batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 3
+ );
+ assert_eq!(
+ batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .value(0),
+ 0
+ );
+ assert!(batch.column(3).is_null(0));
+ }
+
#[tokio::test]
async fn test_partial_update_merge_rejects_delete_like_rows() {
let schema = make_schema();
diff --git a/docs/src/sql.md b/docs/src/sql.md
index fd95b396..a84a956e 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -1886,8 +1886,16 @@ Set either `'ignore-delete' = 'true'` or
`UPDATE_BEFORE` rows during writes and when reading existing files. The default
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`. Advanced partial-update features such as sequence groups, partial
-aggregation, and remove-record-on-delete are not supported.
+`false`.
+
+Rust can read existing 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. Partial aggregation inside sequence groups and
+remove-record options are also not supported.
Rust can read fully materialized compacted files from deletion-vector-enabled
partial-update and aggregation tables. Every split must be raw-convertible,