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 b23bde4f Support append row kinds and historical row updates (#1180)
b23bde4f is described below
commit b23bde4f66d8b069494cb8a3106d5c5ff69f8ca9
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 20 11:06:38 2026 +0800
Support append row kinds and historical row updates (#1180)
---
crates/paimon/src/spec/schema.rs | 48 +-
crates/paimon/src/table/mod.rs | 12 +-
crates/paimon/src/table/row_kind_generator.rs | 56 +-
crates/paimon/src/table/table_commit.rs | 160 +++++-
crates/paimon/src/table/table_merge_input.rs | 3 +-
crates/paimon/src/table/table_update.rs | 17 +-
crates/paimon/src/table/table_update_delete.rs | 2 +-
crates/paimon/src/table/table_write.rs | 70 ++-
crates/paimon/src/table/table_write_precomputed.rs | 5 +-
crates/paimon/src/table/write_builder.rs | 31 +-
crates/paimon/tests/append_row_kind_test.rs | 310 +++++++++++
.../paimon/tests/historical_table_update_test.rs | 581 +++++++++++++++++++++
crates/paimon/tests/table_predicate_delete_test.rs | 18 +-
crates/paimon/tests/table_update_test.rs | 22 +-
14 files changed, 1206 insertions(+), 129 deletions(-)
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 35e152a1..bf257eb7 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -23,7 +23,7 @@ use crate::spec::core_options::{
QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION,
TABLE_READ_SEQUENCE_NUMBER_ENABLED_OPTION,
TABLE_TYPE_OPTION,
};
-use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType,
VarCharType};
+use crate::spec::types::{ArrayType, DataType, MapType, MultisetType, RowType};
use crate::spec::{
remove_field_scoped_options, rename_field_scoped_options,
validate_no_aggregation_on_sequence_field, AggregationConfig, BlobType,
ColumnMove,
@@ -1416,7 +1416,7 @@ impl Schema {
Self::validate_first_row_changelog_producer(options)?;
Self::validate_changelog_producer_requires_primary_keys(options,
primary_keys)?;
Self::validate_read_sequence_number_requires_primary_keys(options,
primary_keys)?;
- Self::validate_rowkind_field(options, primary_keys, fields)?;
+ Self::validate_rowkind_field(options, fields)?;
Self::validate_deletion_vectors(options)?;
Self::validate_bucket_keys(options, fields, partition_keys,
primary_keys)?;
Self::validate_sequence_field(options, fields, partition_keys,
primary_keys)?;
@@ -2120,28 +2120,14 @@ impl Schema {
fn validate_rowkind_field(
options: &HashMap<String, String>,
- primary_keys: &[String],
fields: &[DataField],
) -> crate::Result<()> {
let core = CoreOptions::new(options);
let Some(field_name) = core.rowkind_field() else {
return Ok(());
};
-
- if primary_keys.is_empty() {
- return Err(crate::Error::ConfigInvalid {
- message: "rowkind.field requires a primary-key
table".to_string(),
- });
- }
-
- let producer = core
- .try_changelog_producer()
+ core.try_changelog_producer()
.map_err(Self::options_error_to_config_invalid)?;
- if producer == ChangelogProducer::Input {
- return Err(crate::Error::ConfigInvalid {
- message: "rowkind.field cannot be used with
changelog-producer=input".to_string(),
- });
- }
let Some(field) = fields.iter().find(|f| f.name() == field_name) else {
return Err(crate::Error::ConfigInvalid {
@@ -2149,13 +2135,10 @@ impl Schema {
});
};
- if !matches!(
- field.data_type(),
- DataType::VarChar(v) if v.length() == VarCharType::MAX_LENGTH
- ) {
+ if !matches!(field.data_type(), DataType::Char(_) |
DataType::VarChar(_)) {
return Err(crate::Error::ConfigInvalid {
message: format!(
- "rowkind.field '{field_name}' must be STRING (unbounded
VARCHAR) type"
+ "rowkind.field '{field_name}' must be CHARACTER_STRING
(CHAR or VARCHAR) type"
),
});
}
@@ -6591,17 +6574,13 @@ mod tests {
}
#[test]
- fn rowkind_field_requires_primary_key_table() {
- let err = Schema::builder()
+ fn rowkind_field_accepts_append_tables() {
+ Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("op", DataType::VarChar(VarCharType::string_type()))
.option("rowkind.field", "op")
.build()
- .unwrap_err();
- assert!(
- matches!(err, crate::Error::ConfigInvalid { ref message } if
message.contains("rowkind.field")),
- "got {err:?}"
- );
+ .unwrap();
}
#[test]
@@ -6690,20 +6669,15 @@ mod tests {
}
#[test]
- fn rowkind_field_rejects_changelog_producer_input() {
- let err = Schema::builder()
+ fn rowkind_field_accepts_changelog_producer_input() {
+ Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("op", DataType::VarChar(VarCharType::string_type()))
.primary_key(["id"])
.option("changelog-producer", "input")
.option("rowkind.field", "op")
.build()
- .unwrap_err();
- assert!(
- matches!(err, crate::Error::ConfigInvalid { ref message }
- if message.contains("changelog-producer=input")),
- "got {err:?}"
- );
+ .unwrap();
}
#[test]
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index f11c3426..80c7e396 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -302,7 +302,8 @@ pub struct Table {
query_auth_session: Option<u64>,
rest_env: Option<RESTEnv>,
/// True when this table copy was switched to a historical schema by
- /// [`Table::copy_with_time_travel`]. Such a copy is read-only.
+ /// [`Table::copy_with_time_travel`]. Ordinary writes and publication
reject
+ /// this copy; row-level updates may use it as their read snapshot.
time_traveled: bool,
/// Captured snapshot or empty view, so scans don't resolve latest again.
/// Cleared when [`Table::copy_with_options`] changes the selector.
@@ -801,7 +802,7 @@ impl Table {
})
}
- /// Create a read-only copy pinned to an already resolved snapshot.
+ /// Create a read view pinned to an already resolved snapshot.
///
/// Replaces any selector that originally resolved the snapshot with an
/// explicit `scan.snapshot-id`, so every subsequent scan stage observes
the
@@ -817,7 +818,7 @@ impl Table {
Ok(table)
}
- /// Create a read-only copy pinned to a snapshot from this table and
branch,
+ /// Create a read view pinned to a snapshot from this table and branch,
/// preserving the current read schema and options other than scan
selectors.
///
/// Unlike time travel, pinning must not change the fields used by an
already
@@ -1133,8 +1134,9 @@ impl Table {
})
}
- /// Whether this copy is read-only because its schema was time-traveled or
- /// its read view was explicitly pinned (including an empty view).
+ /// Whether this copy has a time-traveled schema or an explicitly pinned
+ /// read view (including an empty view). Ordinary writes and commits reject
+ /// it; row-level updates retain its snapshot as their conflict baseline.
pub fn is_time_traveled(&self) -> bool {
self.time_traveled
}
diff --git a/crates/paimon/src/table/row_kind_generator.rs
b/crates/paimon/src/table/row_kind_generator.rs
index f037dabe..9631182a 100644
--- a/crates/paimon/src/table/row_kind_generator.rs
+++ b/crates/paimon/src/table/row_kind_generator.rs
@@ -19,17 +19,17 @@
//!
//! Reference: Java `org.apache.paimon.table.sink.RowKindGenerator`.
-use arrow_array::{Array, LargeStringArray, RecordBatch, StringArray};
+use arrow_array::{LargeStringArray, RecordBatch, StringArray};
use crate::spec::{RowKind, TableSchema};
pub struct RowKindGenerator {
- index: usize,
+ field_name: String,
}
impl RowKindGenerator {
pub fn create(schema: &TableSchema, field_name: &str) ->
crate::Result<Self> {
- let index = schema
+ schema
.fields()
.iter()
.position(|f| f.name() == field_name)
@@ -40,28 +40,42 @@ impl RowKindGenerator {
),
source: None,
})?;
- Ok(Self { index })
+ Ok(Self {
+ field_name: field_name.to_owned(),
+ })
}
- pub fn generate(&self, batch: &RecordBatch, row: usize) ->
crate::Result<RowKind> {
- let col = batch.column(self.index);
- if col.is_null(row) {
- return Err(crate::Error::DataInvalid {
- message: "Row kind cannot be null.".to_string(),
- source: None,
- });
- }
- let value = if let Some(strings) =
col.as_any().downcast_ref::<StringArray>() {
- strings.value(row)
+ pub fn generate_batch(&self, batch: &RecordBatch) ->
crate::Result<Vec<RowKind>> {
+ // with_write_type may reorder the physical columns. Resolve once for
+ // the batch, then parse values without a per-row field-name search.
+ let col =
+ batch
+ .column_by_name(&self.field_name)
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "Cannot find rowkind field '{}' in write input",
+ self.field_name
+ ),
+ source: None,
+ })?;
+ let parse = |value: Option<&str>| {
+ value
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "Row kind cannot be null.".into(),
+ source: None,
+ })
+ .and_then(RowKind::from_short_string)
+ };
+ if let Some(strings) = col.as_any().downcast_ref::<StringArray>() {
+ strings.iter().map(parse).collect()
} else if let Some(strings) =
col.as_any().downcast_ref::<LargeStringArray>() {
- strings.value(row)
+ strings.iter().map(parse).collect()
} else {
- return Err(crate::Error::DataInvalid {
- message: "rowkind.field column must be
Utf8/String".to_string(),
+ Err(crate::Error::DataInvalid {
+ message: "rowkind.field column must be Utf8/String".into(),
source: None,
- });
- };
- RowKind::from_short_string(value)
+ })
+ }
}
}
@@ -104,6 +118,6 @@ mod tests {
],
)
.unwrap();
- assert_eq!(gen.generate(&batch, 0).unwrap(), RowKind::Delete);
+ assert_eq!(gen.generate_batch(&batch).unwrap(), vec![RowKind::Delete]);
}
}
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index df9ec40a..20846ea6 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -1796,11 +1796,10 @@ impl TableCommit {
self.check_global_index_sources(latest_snapshot, entries,
new_index_entries)
.await?;
let mut index_entries = new_index_entries.clone();
- index_entries.extend(self.global_index_update_entries(
- &previous,
- entries,
- new_index_entries,
- )?);
+ index_entries.extend(
+ self.global_index_update_entries(&previous, entries,
new_index_entries)
+ .await?,
+ );
let all = Self::merge_index_entries(&previous, &index_entries,
false)?;
self.check_deletion_vector_references(
latest_snapshot,
@@ -2019,34 +2018,69 @@ impl TableCommit {
Ok(all)
}
- fn global_index_update_entries(
+ async fn global_index_update_entries(
&self,
previous_entries: &[IndexManifestEntry],
commit_entries: &[ManifestEntry],
new_index_entries: &[IndexManifestEntry],
) -> Result<Vec<IndexManifestEntry>> {
+ if !previous_entries
+ .iter()
+ .any(|entry| entry.index_file.global_index_meta.is_some())
+ {
+ return Ok(vec![]);
+ }
+ let action =
+
CoreOptions::new(self.table.schema().options()).global_index_column_update_action()?;
+ if action == GlobalIndexColumnUpdateAction::Ignore {
+ return Ok(vec![]);
+ }
let mut updated_cols = HashSet::new();
+ let mut updated_field_ids = HashSet::new();
let mut written_partitions: Vec<Vec<u8>> = Vec::new();
for entry in commit_entries
.iter()
.filter(|entry| *entry.kind() == FileKind::Add)
{
- let Some(write_cols) = entry.file().write_cols.as_ref() else {
- continue;
- };
+ let file = entry.file();
// Dedicated normal/vector/blob INSERT files also carry write_cols.
// Only files targeting existing row IDs can invalidate an
existing index.
- if entry.file().first_row_id.is_none()
- && !write_cols
- .iter()
- .any(|col| col == crate::spec::ROW_ID_FIELD_NAME)
+ if file.first_row_id.is_none()
+ && !file.write_cols.as_ref().is_some_and(|columns| {
+ columns
+ .iter()
+ .any(|col| col == crate::spec::ROW_ID_FIELD_NAME)
+ })
{
continue;
}
- for col in write_cols {
- if !is_system_field(col) {
- updated_cols.insert(col.clone());
- }
+ // Partial files retain their writer's schema, including historical
+ // names. Index metadata identifies fields by ID; comparing against
+ // today's names would miss renames and confuse dropped/readded
names.
+ let historical_schema = if file.schema_id ==
self.table.schema().id() {
+ None
+ } else {
+ Some(self.table.schema_manager().schema(file.schema_id).await?)
+ };
+ let schema = historical_schema
+ .as_deref()
+ .unwrap_or_else(|| self.table.schema());
+ let fields = match &file.write_cols {
+ Some(columns) =>
super::data_evolution_fields::project_by_paths(
+ schema.fields(),
+ &columns
+ .iter()
+ .filter(|name| !is_system_field(name))
+ .cloned()
+ .collect::<Vec<_>>(),
+ )?,
+ // A complete-row update can omit write_cols, as can a compact
+ // normal file which excludes dedicated Blob/vector columns.
+ None => schema.data_file_fields(None),
+ };
+ for field in fields {
+ updated_field_ids.insert(field.id());
+ updated_cols.insert(field.name().to_string());
}
if !written_partitions
.iter()
@@ -2089,8 +2123,8 @@ impl TableCommit {
}
let matched = indexed_field_ids
.iter()
+ .filter(|field_id| updated_field_ids.contains(field_id))
.filter_map(|field_id| field_by_id.get(field_id))
- .filter(|field_name| updated_cols.contains(*field_name))
.cloned()
.collect::<Vec<_>>();
if !matched.is_empty() {
@@ -2102,7 +2136,7 @@ impl TableCommit {
return Ok(vec![]);
}
- match
CoreOptions::new(self.table.schema().options()).global_index_column_update_action()?
{
+ match action {
GlobalIndexColumnUpdateAction::Ignore => Ok(vec![]),
GlobalIndexColumnUpdateAction::DropPartitionIndex => Ok(affected
.into_iter()
@@ -5206,6 +5240,94 @@ mod tests {
assert_eq!(index_entries[0].index_file.file_name, "lumina-0.index");
}
+ #[tokio::test]
+ async fn test_index_update_policy_uses_file_schema_identity() {
+ for mode in ["rename", "drop_readd", "complete_row"] {
+ for action in ["THROW_ERROR", "DROP_PARTITION_INDEX", "IGNORE"] {
+ for extra_field in [false, true] {
+ let file_io = test_file_io();
+ let path =
format!("memory:/index_identity_{mode}_{action}_{extra_field}");
+ setup_dirs(&file_io, &path).await;
+ let old = test_schema();
+ let mut encoded = serde_json::to_value(&old).unwrap();
+ encoded["id"] = serde_json::json!(1);
+ if mode == "rename" {
+ encoded["fields"][0]["name"] =
serde_json::json!("renamed_id");
+ } else if mode == "drop_readd" {
+ encoded["fields"][0]["id"] = serde_json::json!(2);
+ encoded["highestFieldId"] = serde_json::json!(2);
+ }
+ let current: TableSchema =
serde_json::from_value(encoded).unwrap();
+ let current = current.copy_with_options(HashMap::from([(
+ "global-index.column-update-action".into(),
+ action.into(),
+ )]));
+ let index_id = current.fields()[0].id();
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "t"),
+ path.clone(),
+ current,
+ None,
+ );
+ file_io.mkdirs(&format!("{path}/schema/")).await.unwrap();
+ file_io
+ .new_output(&format!("{path}/schema/schema-0"))
+ .unwrap()
+ .write(serde_json::to_vec(&old).unwrap().into())
+ .await
+ .unwrap();
+ let commit = TableCommit::new(table, "test-user".into());
+ let index_file = if extra_field {
+ test_global_index_file_with_extra_fields(
+ "id.index",
+ 1,
+ vec![index_id],
+ 0,
+ 9,
+ )
+ } else {
+ test_global_index_file("id.index", index_id, 0, 9)
+ };
+ let previous = vec![IndexManifestEntry {
+ kind: FileKind::Add,
+ partition: vec![],
+ bucket: 0,
+ index_file,
+ version: 1,
+ }];
+ let mut file = test_data_file("update.parquet", 10);
+ file.first_row_id = Some(0);
+ if mode == "complete_row" {
+ file.schema_id = 1;
+ file.write_cols = None;
+ } else {
+ file.write_cols = Some(vec!["id".into()]);
+ }
+ let entries =
+
commit.messages_to_entries(&[CommitMessage::new(vec![], 0, vec![file])]);
+ let result = commit
+ .global_index_update_entries(&previous, &entries, &[])
+ .await;
+ if mode == "drop_readd" || action == "IGNORE" {
+ assert!(result.unwrap().is_empty(),
"{mode}/{action}/{extra_field}");
+ } else if action == "THROW_ERROR" {
+ let error = result.unwrap_err().to_string();
+ assert!(error.contains("globally indexed columns"),
"{error}");
+ if mode == "rename" {
+ assert!(error.contains("renamed_id"), "{error}");
+ }
+ } else {
+ let deleted = result.unwrap();
+ assert_eq!(deleted.len(), 1,
"{mode}/{action}/{extra_field}");
+ assert_eq!(deleted[0].kind, FileKind::Delete);
+ assert_eq!(deleted[0].index_file,
previous[0].index_file);
+ }
+ }
+ }
+ }
+ }
+
#[tokio::test]
async fn test_partial_update_indexed_column_rejects_by_default() {
let file_io = test_file_io();
diff --git a/crates/paimon/src/table/table_merge_input.rs
b/crates/paimon/src/table/table_merge_input.rs
index 4cb83ed3..8c95ee07 100644
--- a/crates/paimon/src/table/table_merge_input.rs
+++ b/crates/paimon/src/table/table_merge_input.rs
@@ -84,7 +84,8 @@ impl MergeInput {
});
}
crate::spec::CoreOptions::new(table.schema().options()).validate_scan_options()?;
- let schema =
crate::arrow::build_target_arrow_schema(table.schema().fields())?;
+ let schema =
+
crate::arrow::build_target_arrow_schema(&table.schema().fields_with_read_metadata())?;
for column in columns {
schema
.field_with_name(column)
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index 49001080..de07c4d3 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -46,6 +46,10 @@ fn invalid(message: impl Into<String>) -> crate::Error {
/// Row-ID updates infer columns from the input unless configured with
/// [`with_update_type`](Self::with_update_type). Key-based upserts accept a
/// column subset including the keys and match keys within each partition.
+/// Row updates, deletes and shard updates may select a historical read view;
+/// their messages retain its snapshot as the conflict baseline. Publish those
+/// messages using a current table. Upsert and MERGE with INSERT clauses
+/// require a current table as ordinary writes do.
#[derive(Clone)]
pub struct TableUpdate {
table: Table,
@@ -256,6 +260,8 @@ impl TableUpdate {
batches: Vec<RecordBatch>,
upsert_keys: Vec<String>,
) -> crate::Result<Vec<CommitMessage>> {
+ // Unlike partial row updates, an upsert may append complete rows.
+ super::write_builder::ensure_table_write_allowed(&self.table)?;
let mut writer = super::table_upsert::TableUpsert::new(
&self.table,
self.commit_user.clone(),
@@ -281,6 +287,9 @@ impl TableUpdate {
when_matched: Vec<super::WhenMatched>,
when_not_matched: Vec<super::WhenNotMatched>,
) -> crate::Result<Vec<CommitMessage>> {
+ if !when_not_matched.is_empty() {
+ super::write_builder::ensure_table_write_allowed(&self.table)?;
+ }
super::table_merge_into::merge_into(
&self.table,
&self.commit_user,
@@ -314,11 +323,9 @@ impl TableUpdate {
if row_ids.is_empty() {
return Ok(Vec::new());
}
- let mut writer = self
- .table
- .new_write_builder()
- .with_commit_user(self.commit_user.clone())?
- .new_delete()?;
+ // The high-level updater selects a read snapshot; the low-level
+ // ordinary write builder continues rejecting historical handles.
+ let mut writer = super::DataEvolutionDeleteWriter::new(&self.table)?;
writer.add_row_ids(row_ids)?;
writer.prepare_commit().await
}
diff --git a/crates/paimon/src/table/table_update_delete.rs
b/crates/paimon/src/table/table_update_delete.rs
index bd2d99e3..d7029350 100644
--- a/crates/paimon/src/table/table_update_delete.rs
+++ b/crates/paimon/src/table/table_update_delete.rs
@@ -30,7 +30,7 @@ pub(super) async fn delete(
table: &Table,
predicate: Option<Predicate>,
) -> crate::Result<Vec<CommitMessage>> {
- super::write_builder::ensure_table_write_allowed(table)?;
+ super::write_builder::ensure_paimon_table(table)?;
// Partition-only deletes use manifest metadata directly, but still need
// the same unrestricted whole-table grant as row-level deletes. Authorize
// before the empty-target and partition-optimization branches.
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index 80edf85a..39842497 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -424,9 +424,7 @@ impl TableWrite {
}
let row_kind_generator = match core_options.rowkind_field() {
- Some(field_name) if has_primary_keys => {
- Some(RowKindGenerator::create(schema, field_name)?)
- }
+ Some(field_name) => Some(RowKindGenerator::create(schema,
field_name)?),
_ => None,
};
let row_kind_filter = RowKindFilter::of(core_options);
@@ -784,8 +782,9 @@ impl TableWrite {
return writer.write(batch).await;
}
self.ensure_active()?;
- let input = batch;
- let Some(batch) = self.normalize_write_batch(input)? else {
+ let filtered = self.filter_write_input(batch)?;
+ let input = filtered.as_ref().unwrap_or(batch);
+ let Some(batch) = self.normalize_filtered_write_batch(input)? else {
return Ok(());
};
let routing = self.normalize_automatic_routing_input(input)?;
@@ -807,7 +806,11 @@ impl TableWrite {
pub(super) fn normalize_write_batch(&self, batch: &RecordBatch) ->
Result<Option<RecordBatch>> {
let filtered = self.filter_write_input(batch)?;
- let batch =
self.validate_write_batch_schema(filtered.as_ref().unwrap_or(batch))?;
+ self.normalize_filtered_write_batch(filtered.as_ref().unwrap_or(batch))
+ }
+
+ fn normalize_filtered_write_batch(&self, batch: &RecordBatch) ->
Result<Option<RecordBatch>> {
+ let batch = self.validate_write_batch_schema(batch)?;
// Filtering already precedes value normalization. Generate the system
// column now so every surviving PK batch has the same buffer schema.
let batch = self.generate_rowkind_batch(&batch)?;
@@ -870,16 +873,16 @@ impl TableWrite {
/// its optional RowKindFilter, independently of physical KV nullability.
/// Ignored rows never reach value extraction or nested writers.
fn filter_write_input(&self, batch: &RecordBatch) ->
Result<Option<RecordBatch>> {
- if self.primary_key_indices.is_empty() {
+ // Ordinary Arrow append input has no event header. It represents
+ // INSERTs and needs no kind allocation or pre-routing filter.
+ if self.primary_key_indices.is_empty() &&
self.row_kind_generator.is_none() {
return Ok(None);
}
// Reject malformed schemas even when every event would be ignored.
// Empty arrays check equivalent layouts without examining row values.
self.validate_write_batch_schema(&RecordBatch::new_empty(batch.schema()))?;
let kinds = if let Some(generator) = &self.row_kind_generator {
- (0..batch.num_rows())
- .map(|row| generator.generate(batch, row))
- .collect::<Result<Vec<_>>>()?
+ generator.generate_batch(batch)?
} else if let Some(column) =
batch.column_by_name(VALUE_KIND_FIELD_NAME) {
column
.as_any()
@@ -896,15 +899,18 @@ impl TableWrite {
// still identify the event; other engines and partition migrations
// require every declared top-level NOT NULL field.
let delete_required_fields = self.delete_not_null_fields();
+ let projected = self.project_full_write_input(batch)?;
+ let physical = projected.as_ref().unwrap_or(batch);
for (index, field) in self.write_schema.fields().iter().enumerate() {
- if field.is_nullable() || batch.column(index).null_count() == 0 {
+ if field.is_nullable() || physical.column(index).null_count() == 0
{
continue;
}
let delete_required = delete_required_fields
.as_ref()
.is_none_or(|fields| fields.contains(field.name()));
for (row, kind) in kinds.iter().enumerate() {
- if batch.column(index).is_null(row) && (*kind !=
RowKind::Delete || delete_required)
+ if physical.column(index).is_null(row)
+ && (*kind != RowKind::Delete || delete_required)
{
return Err(crate::Error::DataInvalid {
message: format!("Cannot write null to non-null
column({})", field.name()),
@@ -913,14 +919,25 @@ impl TableWrite {
}
}
}
- let Some(filter) = &self.row_kind_filter else {
- return Ok(None);
- };
- let rows = kinds
- .into_iter()
- .enumerate()
- .filter_map(|(row, kind)| filter.test(kind).then_some(row))
- .collect::<Vec<_>>();
+ let mut rows = Vec::with_capacity(batch.num_rows());
+ for (row, kind) in kinds.into_iter().enumerate() {
+ if self
+ .row_kind_filter
+ .as_ref()
+ .is_some_and(|filter| !filter.test(kind))
+ {
+ continue;
+ }
+ if self.primary_key_indices.is_empty()
+ && !matches!(kind, RowKind::Insert | RowKind::UpdateAfter)
+ {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Append only writer cannot accept row
with RowKind {kind:?}"),
+ source: None,
+ });
+ }
+ rows.push(row);
+ }
if rows.len() == batch.num_rows() {
Ok(None)
} else {
@@ -1265,6 +1282,11 @@ impl TableWrite {
}
fn generate_rowkind_batch(&self, batch: &RecordBatch) ->
Result<RecordBatch> {
+ // Append rows have already been filtered and checked. Their events do
+ // not become a physical KV system column.
+ if self.primary_key_indices.is_empty() {
+ return Ok(batch.clone());
+ }
let Some(generator) = &self.row_kind_generator else {
// Every PK batch sent to a buffered writer uses the same schema.
// Mixing default INSERT batches with explicit kinds must neither
@@ -1291,9 +1313,11 @@ impl TableWrite {
});
}
- let kinds = (0..batch.num_rows())
- .map(|row| generator.generate(batch, row).map(|kind|
kind.to_value()))
- .collect::<Result<Vec<_>>>()?;
+ let kinds = generator
+ .generate_batch(batch)?
+ .into_iter()
+ .map(|kind| kind.to_value())
+ .collect();
Self::add_per_row_value_kind_column(batch, kinds)
}
diff --git a/crates/paimon/src/table/table_write_precomputed.rs
b/crates/paimon/src/table/table_write_precomputed.rs
index 35f6ad30..3730d3d0 100644
--- a/crates/paimon/src/table/table_write_precomputed.rs
+++ b/crates/paimon/src/table/table_write_precomputed.rs
@@ -63,8 +63,9 @@ impl TableWrite {
upper_bound - 1,
)));
}
- let input = batch;
- let Some(batch) = self.normalize_write_batch(input)? else {
+ let filtered = self.filter_write_input(batch)?;
+ let input = filtered.as_ref().unwrap_or(batch);
+ let Some(batch) = self.normalize_filtered_write_batch(input)? else {
return Ok(());
};
let partitions = self.precomputed_partition_bytes(input, &batch)?;
diff --git a/crates/paimon/src/table/write_builder.rs
b/crates/paimon/src/table/write_builder.rs
index a073c7ec..2c3744ed 100644
--- a/crates/paimon/src/table/write_builder.rs
+++ b/crates/paimon/src/table/write_builder.rs
@@ -262,7 +262,11 @@ impl<'a> PaimonWriteBuilder<'a> {
/// Create a configured table update object.
pub fn new_update(&self) -> crate::Result<TableUpdate> {
- ensure_table_write_allowed(self.table)?;
+ // Row-level updates can select an older read snapshot. Their partial
+ // files retain that schema and their messages carry the snapshot used
+ // for conflict detection. Appending and publishing still require a
+ // current table, including append-capable operations on TableUpdate.
+ ensure_paimon_table(self.table)?;
if self.overwrite {
return Err(crate::Error::Unsupported {
message: "TableUpdate does not support overwrite".to_string(),
@@ -304,16 +308,7 @@ pub(super) fn ensure_table_write_authorized(table: &Table)
-> crate::Result<()>
}
pub(super) fn ensure_table_write_allowed(table: &Table) -> crate::Result<()> {
- let options = crate::spec::CoreOptions::new(table.schema().options());
- let declared = options.table_type()?;
- if declared.requires_table_engine() {
- return Err(crate::Error::Unsupported {
- message: format!(
- "table '{}' is declared '{declared}' and cannot be written as
a Paimon table",
- table.identifier().full_name()
- ),
- });
- }
+ ensure_paimon_table(table)?;
// A time-travel table may carry a historical schema.
let selector =
crate::spec::CoreOptions::new(table.schema().options()).try_time_travel_selector();
@@ -327,6 +322,20 @@ pub(super) fn ensure_table_write_allowed(table: &Table) ->
crate::Result<()> {
Ok(())
}
+pub(super) fn ensure_paimon_table(table: &Table) -> crate::Result<()> {
+ let options = crate::spec::CoreOptions::new(table.schema().options());
+ let declared = options.table_type()?;
+ if declared.requires_table_engine() {
+ return Err(crate::Error::Unsupported {
+ message: format!(
+ "table '{}' is declared '{declared}' and cannot be written as
a Paimon table",
+ table.identifier().full_name()
+ ),
+ });
+ }
+ Ok(())
+}
+
pub(super) fn validate_commit_user(commit_user: &str) -> crate::Result<()> {
let is_invalid = commit_user.is_empty()
|| commit_user == "."
diff --git a/crates/paimon/tests/append_row_kind_test.rs
b/crates/paimon/tests/append_row_kind_test.rs
new file mode 100644
index 00000000..313e5d40
--- /dev/null
+++ b/crates/paimon/tests/append_row_kind_test.rs
@@ -0,0 +1,310 @@
+// 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.
+
+mod common;
+
+use arrow_array::{ArrayRef, Int32Array, Int8Array, RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs};
+use futures::TryStreamExt;
+use paimon::spec::{CharType, DataType, IntType, Schema, TableSchema,
VarCharType};
+use paimon::table::Table;
+use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder;
+use std::sync::Arc;
+
+async fn make_table(evolution: bool, options: &[(&str, &str)]) -> Table {
+ let mut schema = Schema::builder()
+ .column("id", DataType::Int(IntType::with_nullable(false)))
+ .column("op", DataType::VarChar(VarCharType::string_type()))
+ .column("pt", DataType::VarChar(VarCharType::string_type()))
+ .partition_keys(["pt"])
+ .option("rowkind.field", "op")
+ .option("data-evolution.enabled", evolution.to_string())
+ .option("row-tracking.enabled", evolution.to_string());
+ for (key, value) in options {
+ schema = schema.option(*key, *value);
+ }
+ let path = "memory:/append-row-kind";
+ let (io, table) = memory_table(path, TableSchema::new(0,
&schema.build().unwrap()));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ table
+}
+
+fn batch(rows: &[(Option<i32>, &str, &str)]) -> RecordBatch {
+ RecordBatch::try_from_iter(vec![
+ (
+ "id",
+ Arc::new(Int32Array::from_iter(rows.iter().map(|x| x.0))) as
ArrayRef,
+ ),
+ (
+ "op",
+ Arc::new(StringArray::from_iter_values(rows.iter().map(|x| x.1)))
as ArrayRef,
+ ),
+ (
+ "pt",
+ Arc::new(StringArray::from_iter_values(rows.iter().map(|x| x.2)))
as ArrayRef,
+ ),
+ ])
+ .unwrap()
+}
+
+async fn ids(table: &Table) -> Vec<i32> {
+ let read = table.new_read_builder();
+ let plan = read.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = read
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut ids: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ ids.sort();
+ ids
+}
+
+#[tokio::test]
+async fn append_rejects_retracts_and_filters_only_configured_events() {
+ for evolution in [false, true] {
+ for kind in ["-D", "-U"] {
+ let table = make_table(evolution, &[]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let error = writer
+ .write_arrow_batch(&batch(&[(Some(1), kind, "bad")]))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("Append"), "{error}");
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ writer.close().await;
+ }
+ let table = make_table(evolution, &[("ignore-delete", "true")]).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&batch(&[
+ (Some(1), "-D", "bad"),
+ (Some(2), "+U", "keep"),
+ (Some(3), "-U", "bad"),
+ (Some(4), "+I", "keep"),
+ ]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(
+ messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .map(|f| f.row_count)
+ .sum::<i64>(),
+ 2
+ );
+ builder.new_commit().commit(messages).await.unwrap();
+ writer.close().await;
+ assert_eq!(ids(&table).await, vec![2, 4]);
+ }
+}
+
+#[tokio::test]
+async fn
append_checks_required_values_before_filter_and_keeps_ignore_before_distinct() {
+ let table = make_table(false, &[("ignore-delete", "true")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ let error = writer
+ .write_arrow_batch(&batch(&[(None, "-D", "bad")]))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("non-null column(id)"), "{error}");
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ writer.close().await;
+
+ let table = make_table(false, &[("ignore-update-before", "true")]).await;
+ let mut writer = table.new_write_builder().new_write().unwrap();
+ writer
+ .write_arrow_batch(&batch(&[(Some(1), "-U", "bad")]))
+ .await
+ .unwrap();
+ let error = writer
+ .write_arrow_batch(&batch(&[(Some(2), "-D", "bad")]))
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("Append"), "{error}");
+ assert!(writer.prepare_commit().await.unwrap().is_empty());
+ writer.close().await;
+}
+
+#[tokio::test]
+async fn append_selected_write_type_filters_the_same_rows_as_routing() {
+ for precomputed in [false, true] {
+ for reordered in [false, true] {
+ let table = make_table(
+ false,
+ &[
+ ("ignore-delete", "true"),
+ ("bucket", "2"),
+ ("bucket-key", "id"),
+ ],
+ )
+ .await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ // Full input retains omitted event/routing fields, whereas a
+ // reordered input resolves the generator at its current position.
+ let names = if reordered {
+ vec!["op", "id", "pt"]
+ } else {
+ vec!["id"]
+ };
+ writer
+
.with_write_type(names.into_iter().map(str::to_owned).collect())
+ .unwrap();
+ let input = batch(&[(Some(1), "-D", "bad"), (Some(2), "+I",
"keep")]);
+ let input = if reordered {
+ input.project(&[1, 0, 2]).unwrap()
+ } else {
+ input
+ };
+ writer
+ .write_arrow(&[input], precomputed.then_some(0))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ assert_eq!(
+ messages
+ .iter()
+ .flat_map(|m| &m.new_files)
+ .map(|f| f.row_count)
+ .sum::<i64>(),
+ 1
+ );
+ builder.new_commit().commit(messages).await.unwrap();
+ writer.close().await;
+ assert_eq!(ids(&table).await, vec![2]);
+ }
+ }
+}
+
+#[tokio::test]
+async fn generated_primary_key_kinds_preserve_input_changelog_events() {
+ for op_type in [
+ DataType::VarChar(VarCharType::string_type()),
+ DataType::VarChar(VarCharType::new(2).unwrap()),
+ DataType::Char(CharType::new(2).unwrap()),
+ ] {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .column("op", op_type)
+ .primary_key(["id"])
+ .option("bucket", "1")
+ .option("rowkind.field", "op")
+ .option("changelog-producer", "input")
+ .build()
+ .unwrap();
+ let path = "memory:/generated-input-changelog";
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ let input = RecordBatch::try_from_iter(vec![
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![1, 1, 2, 2])) as ArrayRef,
+ ),
+ (
+ "value",
+ Arc::new(Int32Array::from(vec![10, 20, 25, 30])) as ArrayRef,
+ ),
+ (
+ "op",
+ Arc::new(StringArray::from(vec!["+I", "-D", "-U", "+U"])) as
ArrayRef,
+ ),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&input).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let logs: Vec<_> = messages
+ .iter()
+ .flat_map(|m| &m.new_changelog_files)
+ .collect();
+ assert_eq!(logs.len(), 1);
+ assert_eq!(logs[0].row_count, 4);
+ assert_eq!(logs[0].delete_row_count, Some(2));
+ let bytes = io
+ .new_input(&format!("{path}/bucket-0/{}", logs[0].file_name))
+ .unwrap()
+ .read()
+ .await
+ .unwrap();
+ let batches = ParquetRecordBatchReaderBuilder::try_new(bytes)
+ .unwrap()
+ .build()
+ .unwrap()
+ .collect::<Result<Vec<_>, _>>()
+ .unwrap();
+ let values: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column_by_name("value")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ let kinds: Vec<_> = batches
+ .iter()
+ .flat_map(|batch| {
+ batch
+ .column_by_name("_VALUE_KIND")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int8Array>()
+ .unwrap()
+ .values()
+ .to_vec()
+ })
+ .collect();
+ assert_eq!(values, vec![10, 20, 25, 30]);
+ assert_eq!(kinds, vec![0, 3, 1, 2]);
+ builder.new_commit().commit(messages).await.unwrap();
+ assert_eq!(ids(&table).await, vec![2]);
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(snapshot.changelog_record_count(), Some(4));
+ writer.close().await;
+ }
+}
diff --git a/crates/paimon/tests/historical_table_update_test.rs
b/crates/paimon/tests/historical_table_update_test.rs
new file mode 100644
index 00000000..4a2a92eb
--- /dev/null
+++ b/crates/paimon/tests/historical_table_update_test.rs
@@ -0,0 +1,581 @@
+// 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.
+
+mod common;
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use arrow_array::{Array, ArrayRef, Int32Array, Int64Array, RecordBatch};
+use common::incremental_helpers::{memory_table, persist_table_schema,
setup_dirs, write_batch};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, Datum, IntType, PredicateBuilder, Schema,
TableSchema};
+use paimon::table::{
+ CommitMessage, MergeAssignment, MergeSource, Table, UpdateAssignment,
WhenMatched,
+ WhenNotMatched,
+};
+
+fn batch(names: &[&str], values: &[Vec<i32>]) -> RecordBatch {
+ RecordBatch::try_from_iter(names.iter().zip(values).map(|(name, values)| {
+ (
+ *name,
+ Arc::new(Int32Array::from(values.clone())) as ArrayRef,
+ )
+ }))
+ .unwrap()
+}
+
+async fn evolved_table(path: &str) -> (Table, Table) {
+ let schema = |extra| {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .column("keep", DataType::Int(IntType::new()))
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .option("deletion-vectors.enabled", "true");
+ if extra {
+ builder = builder.column("extra", DataType::Int(IntType::new()));
+ }
+ TableSchema::new(i64::from(extra), &builder.build().unwrap())
+ };
+ let (io, original) = memory_table(path, schema(false));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, original.schema()).await;
+ write_batch(
+ &original,
+ &batch(
+ &["id", "value", "keep"],
+ &[vec![1, 2], vec![10, 20], vec![100, 200]],
+ ),
+ )
+ .await;
+ let latest_schema = schema(true);
+ persist_table_schema(&io, path, &latest_schema).await;
+ let latest = original
+ .copy_with_resolved_schema(latest_schema, "main")
+ .unwrap();
+ write_batch(
+ &latest,
+ &batch(
+ &["id", "value", "keep", "extra"],
+ &[vec![3], vec![30], vec![300], vec![999]],
+ ),
+ )
+ .await;
+ let historical = latest
+ .copy_with_time_travel(HashMap::from([("scan.snapshot-id".into(),
"1".into())]))
+ .await
+ .unwrap();
+ assert_eq!(historical.schema().id(), 0);
+ assert!(historical.is_time_traveled());
+ (latest, historical)
+}
+
+async fn commit(table: &Table, messages: Vec<CommitMessage>) {
+ table
+ .new_write_builder()
+ .new_commit()
+ .commit(messages)
+ .await
+ .unwrap();
+}
+
+async fn rows(table: &Table) -> Vec<Vec<Option<i64>>> {
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<_> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows: Vec<Vec<Option<i64>>> = Vec::new();
+ for batch in batches {
+ for row in 0..batch.num_rows() {
+ rows.push(
+ batch
+ .columns()
+ .iter()
+ .map(|array| {
+ array.is_valid(row).then(|| {
+ if let Some(array) =
array.as_any().downcast_ref::<Int32Array>() {
+ i64::from(array.value(row))
+ } else {
+ array
+ .as_any()
+ .downcast_ref::<Int64Array>()
+ .unwrap()
+ .value(row)
+ }
+ })
+ })
+ .collect(),
+ );
+ }
+ }
+ rows.sort_by_key(|row| row[0]);
+ rows
+}
+
+fn changed() -> RecordBatch {
+ RecordBatch::try_from_iter([
+ ("_ROW_ID", Arc::new(Int64Array::from(vec![0])) as ArrayRef),
+ ("value", Arc::new(Int32Array::from(vec![11])) as ArrayRef),
+ ])
+ .unwrap()
+}
+
+#[tokio::test]
+async fn historical_updates_keep_schema_and_conflict_baseline() {
+ for operation in [
+ "row_id",
+ "incremental",
+ "groups",
+ "predicate",
+ "shard",
+ "merge",
+ ] {
+ let (latest, historical) =
+
evolved_table(&format!("memory:/historical_update_{operation}")).await;
+ let mut update = historical.new_write_builder().new_update().unwrap();
+ update.with_update_type(vec!["value".into()]).unwrap();
+ let messages = match operation {
+ "row_id" => update
+ .update_by_arrow_with_row_id(vec![changed()])
+ .await
+ .unwrap(),
+ "groups" => update
+ .update_by_arrow_batches_with_row_id([Ok(vec![changed()])])
+ .await
+ .unwrap(),
+ "incremental" => update
+ .new_update_by_row_id()
+ .await
+ .unwrap()
+ .update_columns(vec![changed()], vec!["value".into()])
+ .await
+ .unwrap(),
+ "predicate" => update
+ .update_by_predicate(
+ Some(
+ PredicateBuilder::new(historical.schema().fields())
+ .equal("id", Datum::Int(1))
+ .unwrap(),
+ ),
+ vec![(
+ "value".into(),
+
UpdateAssignment::Array(vec![Arc::new(Int32Array::from(vec![11]))]),
+ )],
+ Vec::new(),
+ )
+ .await
+ .unwrap(),
+ "merge" => update
+ .merge_into(
+ MergeSource::Batches(vec![batch(&["id", "value"],
&[vec![1], vec![11]])]),
+ vec![("id".into(), "id".into())],
+ vec![WhenMatched {
+ condition: None,
+ delete: false,
+ assignments: vec![(
+ "value".into(),
+ MergeAssignment::SourceColumn("value".into()),
+ )],
+ }],
+ Vec::new(),
+ )
+ .await
+ .unwrap(),
+ "shard" => {
+ let mut shard = update.new_shard_updater(0, 1,
None).await.unwrap();
+ let inputs: Vec<_> =
shard.arrow_reader().unwrap().try_collect().await.unwrap();
+
assert_eq!(inputs.iter().map(RecordBatch::num_rows).sum::<usize>(), 2);
+ for input in inputs {
+ let ids = input
+ .column_by_name("id")
+ .unwrap()
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ shard
+ .update_by_arrow_batch(batch(
+ &["value"],
+ &[ids
+ .values()
+ .iter()
+ .map(|id| if *id == 1 { 11 } else { 20 })
+ .collect()],
+ ))
+ .await
+ .unwrap();
+ }
+ shard.prepare_commit().await.unwrap()
+ }
+ _ => unreachable!(),
+ };
+ assert!(!messages.is_empty(), "{operation}");
+ for message in &messages {
+ assert_eq!(message.check_from_snapshot, Some(1), "{operation}");
+ for file in &message.new_files {
+ assert_eq!(file.schema_id, 0);
+ assert_eq!(file.write_cols, Some(vec!["value".into()]));
+ }
+ }
+ commit(&latest, messages).await;
+ let expected = vec![
+ vec![Some(1), Some(11), Some(100), None],
+ vec![Some(2), Some(20), Some(200), None],
+ vec![Some(3), Some(30), Some(300), Some(999)],
+ ];
+ assert_eq!(rows(&latest).await, expected, "{operation}");
+ // Selection and check_from_snapshot must both keep snapshot 1. A
+ // second update of the same old file must not silently overwrite it.
+ let stale = update
+ .update_by_arrow_with_row_id(vec![changed()])
+ .await
+ .unwrap();
+ assert!(stale
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ let error = latest
+ .new_write_builder()
+ .new_commit()
+ .commit(stale)
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("conflict"),
+ "{operation}: {error}"
+ );
+ assert_eq!(rows(&latest).await, expected);
+ }
+}
+
+#[tokio::test]
+async fn historical_deletes_preserve_newer_rows() {
+ for operation in ["row_id", "predicate"] {
+ let (latest, historical) =
+
evolved_table(&format!("memory:/historical_delete_{operation}")).await;
+ let update = historical.new_write_builder().new_update().unwrap();
+ let messages = if operation == "row_id" {
+ update.delete_by_row_id(vec![0, 0]).await.unwrap()
+ } else {
+ update
+ .delete_by_predicate(Some(
+ PredicateBuilder::new(historical.schema().fields())
+ .equal("id", Datum::Int(1))
+ .unwrap(),
+ ))
+ .await
+ .unwrap()
+ };
+ assert!(!messages.is_empty());
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ commit(&latest, messages).await;
+ assert_eq!(
+ rows(&latest).await,
+ vec![
+ vec![Some(2), Some(20), Some(200), None],
+ vec![Some(3), Some(30), Some(300), Some(999)],
+ ]
+ );
+ }
+}
+
+#[tokio::test]
+async fn historical_updater_cannot_append_or_publish() {
+ let (latest, historical) =
evolved_table("memory:/historical_write_guards").await;
+ assert!(historical.new_write_builder().new_write().is_err());
+ assert!(historical.new_write_builder().try_new_commit().is_err());
+ assert!(historical.new_write_builder().new_delete().is_err());
+ assert!(historical
+ .new_write_builder()
+ .new_data_evolution_writer(vec!["value".into()])
+ .is_err());
+ let update = historical.new_write_builder().new_update().unwrap();
+ let upsert = update
+ .upsert_by_arrow_with_key(
+ vec![batch(
+ &["id", "value", "keep"],
+ &[vec![1], vec![11], vec![100]],
+ )],
+ vec!["id".into()],
+ )
+ .await
+ .unwrap_err();
+ assert!(upsert.to_string().contains("time-travel option"));
+ let merge = update
+ .merge_into(
+ MergeSource::Batches(Vec::new()),
+ Vec::new(),
+ Vec::new(),
+ vec![WhenNotMatched {
+ condition: None,
+ assignments: Vec::new(),
+ }],
+ )
+ .await
+ .unwrap_err();
+ assert!(merge.to_string().contains("time-travel option"));
+ assert_eq!(
+ rows(&latest).await,
+ vec![
+ vec![Some(1), Some(10), Some(100), None],
+ vec![Some(2), Some(20), Some(200), None],
+ vec![Some(3), Some(30), Some(300), Some(999)],
+ ]
+ );
+}
+
+#[tokio::test]
+async fn historical_partial_files_follow_field_ids_after_schema_changes() {
+ for change in ["rename", "drop_readd", "widen"] {
+ let (table, historical) =
+
evolved_table(&format!("memory:/historical_fields_{change}")).await;
+ let mut schema = serde_json::to_value(table.schema()).unwrap();
+ schema["id"] = 2.into();
+ match change {
+ "rename" => schema["fields"][1]["name"] = "renamed".into(),
+ "drop_readd" => {
+ schema["fields"][1]["id"] = 4.into();
+ schema["highestFieldId"] = 4.into();
+ }
+ "widen" => schema["fields"][1]["type"] = "BIGINT".into(),
+ _ => unreachable!(),
+ }
+ let schema: TableSchema = serde_json::from_value(schema).unwrap();
+ persist_table_schema(table.file_io(), table.location(), &schema).await;
+ let latest = table.copy_with_resolved_schema(schema, "main").unwrap();
+ let value = if change == "widen" { 1i64 << 40 } else { 40 };
+ let values: ArrayRef = if change == "widen" {
+ Arc::new(Int64Array::from(vec![value]))
+ } else {
+ Arc::new(Int32Array::from(vec![40]))
+ };
+ let input = RecordBatch::try_new(
+
paimon::arrow::build_target_arrow_schema(latest.schema().fields()).unwrap(),
+ vec![
+ Arc::new(Int32Array::from(vec![4])),
+ values,
+ Arc::new(Int32Array::from(vec![400])),
+ Arc::new(Int32Array::from(vec![1000])),
+ ],
+ )
+ .unwrap();
+ write_batch(&latest, &input).await;
+ let messages = historical
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![changed()])
+ .await
+ .unwrap();
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ assert!(messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .all(|file| file.schema_id == 0 && file.write_cols ==
Some(vec!["value".into()])));
+ commit(&latest, messages).await;
+ let expected = if change == "drop_readd" {
+ [None, None, None]
+ } else {
+ [Some(11), Some(20), Some(30)]
+ };
+ assert_eq!(
+ rows(&latest).await,
+ vec![
+ vec![Some(1), expected[0], Some(100), None],
+ vec![Some(2), expected[1], Some(200), None],
+ vec![Some(3), expected[2], Some(300), Some(999)],
+ vec![Some(4), Some(value), Some(400), Some(1000)],
+ ],
+ "{change}"
+ );
+ }
+}
+
+#[tokio::test]
+async fn historical_deletion_vectors_cannot_restore_a_concurrent_deletion() {
+ let (latest, historical) =
evolved_table("memory:/historical_delete_conflict").await;
+ let newer = latest
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .delete_by_row_id(vec![1])
+ .await
+ .unwrap();
+ commit(&latest, newer).await;
+ let stale = historical
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .delete_by_row_id(vec![0])
+ .await
+ .unwrap();
+ assert!(stale
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ let error = latest
+ .new_write_builder()
+ .new_commit()
+ .commit(stale)
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().to_ascii_lowercase().contains("conflict"),
+ "{error}"
+ );
+ assert_eq!(
+ rows(&latest).await,
+ vec![
+ vec![Some(1), Some(10), Some(100), None],
+ vec![Some(3), Some(30), Some(300), Some(999)],
+ ]
+ );
+}
+
+#[tokio::test]
+async fn captured_empty_update_views_do_not_observe_later_rows() {
+ let (latest, _) = evolved_table("memory:/captured_empty_update").await;
+ let empty = latest.copy_with_pinned_snapshot(None);
+ let update = empty.new_write_builder().new_update().unwrap();
+ let messages = update
+ .update_by_predicate(
+ None,
+ vec![(
+ "value".into(),
+ UpdateAssignment::Function(Arc::new(|_| panic!("empty view
evaluated a callback"))),
+ )],
+ vec!["value".into()],
+ )
+ .await
+ .unwrap();
+ assert!(messages.is_empty());
+ assert!(update.delete_by_predicate(None).await.unwrap().is_empty());
+ let mut shard = update.new_shard_updater(0, 1, None).await.unwrap();
+ let batches: Vec<_> =
shard.arrow_reader().unwrap().try_collect().await.unwrap();
+ assert!(batches.is_empty());
+ assert!(shard.prepare_commit().await.unwrap().is_empty());
+ let messages = update
+ .merge_into(
+ MergeSource::Batches(vec![batch(&["id", "value"], &[vec![1],
vec![11]])]),
+ vec![("id".into(), "id".into())],
+ vec![WhenMatched {
+ condition: None,
+ delete: false,
+ assignments: vec![(
+ "value".into(),
+ MergeAssignment::SourceColumn("value".into()),
+ )],
+ }],
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ assert!(messages.is_empty());
+ let error = update
+ .update_by_arrow_with_row_id(vec![changed()])
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("No files with row tracking"),
+ "{error}"
+ );
+ assert_eq!(rows(&latest).await.len(), 3);
+}
+
+#[tokio::test]
+async fn row_id_merge_reads_the_independently_selected_source_view() {
+ for source_view in ["current", "snapshot", "empty"] {
+ let (latest, historical) =
+
evolved_table(&format!("memory:/merge_source_view_{source_view}")).await;
+ let changed = RecordBatch::try_from_iter([
+ ("_ROW_ID", Arc::new(Int64Array::from(vec![0])) as ArrayRef),
+ ("value", Arc::new(Int32Array::from(vec![99])) as ArrayRef),
+ ])
+ .unwrap();
+ commit(
+ &latest,
+ latest
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![changed])
+ .await
+ .unwrap(),
+ )
+ .await;
+ let source = match source_view {
+ "snapshot" => latest
+
.copy_with_time_travel(HashMap::from([("scan.snapshot-id".into(), "3".into())]))
+ .await
+ .unwrap(),
+ "empty" => latest.copy_with_pinned_snapshot(None),
+ _ => latest.clone(),
+ };
+ let assignment = if source_view == "empty" {
+ MergeAssignment::Value(UpdateAssignment::Function(Arc::new(|_| {
+ panic!("an empty MERGE source evaluated an assignment")
+ })))
+ } else {
+ MergeAssignment::SourceColumn("value".into())
+ };
+ let messages = historical
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .merge_into(
+ MergeSource::Table(Arc::new(source)),
+ vec![("_ROW_ID".into(), "_ROW_ID".into())],
+ vec![WhenMatched {
+ condition: None,
+ delete: false,
+ assignments: vec![("keep".into(), assignment)],
+ }],
+ Vec::new(),
+ )
+ .await
+ .unwrap();
+ let kept = if source_view == "empty" {
+ assert!(messages.is_empty());
+ [100, 200]
+ } else {
+ assert!(!messages.is_empty());
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ commit(&latest, messages).await;
+ [99, 20]
+ };
+ assert_eq!(
+ rows(&latest).await,
+ vec![
+ vec![Some(1), Some(99), Some(kept[0]), None],
+ vec![Some(2), Some(20), Some(kept[1]), None],
+ vec![Some(3), Some(30), Some(300), Some(999)],
+ ],
+ "{source_view}"
+ );
+ }
+}
diff --git a/crates/paimon/tests/table_predicate_delete_test.rs
b/crates/paimon/tests/table_predicate_delete_test.rs
index cc30f344..1dc8e441 100644
--- a/crates/paimon/tests/table_predicate_delete_test.rs
+++ b/crates/paimon/tests/table_predicate_delete_test.rs
@@ -495,7 +495,7 @@ async fn row_delete_rejects_concurrent_dv_replacement() {
}
#[tokio::test]
-async fn
predicate_delete_rejects_time_travel_auth_and_inconsistent_field_indices() {
+async fn historical_predicate_delete_keeps_auth_and_field_index_validation() {
let table = table(&[]).await;
seed(&table).await;
let pb = PredicateBuilder::new(table.schema().fields());
@@ -516,7 +516,18 @@ async fn
predicate_delete_rejects_time_travel_auth_and_inconsistent_field_indice
.contains("does not match the table schema"));
let historical =
table.copy_with_options(HashMap::from([("scan.snapshot-id".into(),
"1".into())]));
- assert!(historical.new_write_builder().new_update().is_err());
+ let messages = historical
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .delete_by_predicate(Some(predicate.clone()))
+ .await
+ .unwrap();
+ assert!(!messages.is_empty());
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ assert!(historical.new_write_builder().new_write().is_err());
let auth = table.copy_with_options(HashMap::from([(
"query-auth.enabled".into(),
"true".into(),
@@ -530,6 +541,9 @@ async fn
predicate_delete_rejects_time_travel_auth_and_inconsistent_field_indice
.unwrap_err();
assert!(error.to_string().contains("query-auth.enabled"));
assert_eq!(ids(&table).await, vec![0, 1, 2, 3]);
+ write_batch(&table, &batch(vec![4], vec![Some("a")], vec![50],
vec![1])).await;
+ commit(&table, messages).await;
+ assert_eq!(ids(&table).await, vec![2, 3, 4]);
}
#[tokio::test]
diff --git a/crates/paimon/tests/table_update_test.rs
b/crates/paimon/tests/table_update_test.rs
index 8689dd5e..cd39b34d 100644
--- a/crates/paimon/tests/table_update_test.rs
+++ b/crates/paimon/tests/table_update_test.rs
@@ -1373,16 +1373,34 @@ async fn
empty_update_type_means_all_columns_for_upsert_only() {
}
#[tokio::test]
-async fn update_builder_rejects_overwrite_and_time_travel() {
+async fn
update_builder_rejects_overwrite_but_allows_historical_partial_updates() {
let table = evolution_table().await;
assert!(table
.new_write_builder()
.with_overwrite()
.new_update()
.is_err());
+ seed(&table).await;
let historical =
table.copy_with_options(HashMap::from([("scan.snapshot-id".into(),
"1".into())]));
- assert!(historical.new_write_builder().new_update().is_err());
+ let messages = historical
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![matched(vec![0], &[("value",
vec![11])])])
+ .await
+ .unwrap();
+ assert!(!messages.is_empty());
+ assert!(messages
+ .iter()
+ .all(|message| message.check_from_snapshot == Some(1)));
+ assert!(historical.new_write_builder().new_write().is_err());
+ assert!(historical.new_write_builder().try_new_commit().is_err());
+ commit(&table, messages).await;
+ assert_eq!(
+ read_rows(&table).await,
+ vec![vec![1, 11, 100], vec![2, 20, 200], vec![3, 30, 300]]
+ );
}
#[tokio::test]