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 d53f64a0 Align inline Blob layouts and updates with Java (#1039)
d53f64a0 is described below
commit d53f64a0f8c2cb2065839b1c7f0dab6fe8e85ad5
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Oct 5 18:37:40 2026 +0800
Align inline Blob layouts and updates with Java (#1039)
---
crates/paimon/src/spec/core_options.rs | 44 +-
crates/paimon/src/spec/schema.rs | 147 ++++++-
crates/paimon/src/table/data_evolution_fields.rs | 32 +-
crates/paimon/src/table/data_evolution_reader.rs | 12 +-
crates/paimon/src/table/data_evolution_writer.rs | 25 +-
.../src/table/dedicated_format_file_writer.rs | 17 +-
crates/paimon/src/table/inline_blob.rs | 3 -
crates/paimon/src/table/mod.rs | 57 ++-
crates/paimon/tests/rest_catalog_test.rs | 64 +++
crates/paimon/tests/table_update_test.rs | 446 +++++++++++++++++++++
10 files changed, 805 insertions(+), 42 deletions(-)
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 1775b5b7..ae04d111 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -180,6 +180,7 @@ const DEFAULT_BTREE_INDEX_HIGH_PRIORITY_POOL_RATIO: f64 =
0.1;
const BLOB_AS_DESCRIPTOR_OPTION: &str = "blob-as-descriptor";
pub(crate) const BLOB_FIELD_OPTION: &str = "blob-field";
pub(crate) const BLOB_DESCRIPTOR_FIELD_OPTION: &str = "blob-descriptor-field";
+pub(crate) const BLOB_DESCRIPTOR_FIELD_FALLBACK: &str =
"blob.stored-descriptor-fields";
pub(crate) const BLOB_VIEW_FIELD_OPTION: &str = "blob-view-field";
pub const BLOB_VIEW_RESOLVE_ENABLED_OPTION: &str = "blob-view.resolve.enabled";
const PK_VECTOR_INDEX_COLUMNS_OPTION: &str = "pk-vector.index.columns";
@@ -1788,7 +1789,13 @@ impl<'a> CoreOptions<'a> {
/// Comma-separated BLOB field names stored as serialized BlobDescriptor
/// bytes inline in normal data files (no .blob files for these fields).
pub fn blob_descriptor_fields(&self) -> HashSet<String> {
- self.parse_csv_set(BLOB_DESCRIPTOR_FIELD_OPTION)
+ // Java ConfigOption.withFallbackKeys: presence of the canonical key,
+ // including an empty value, takes precedence over the fallback key.
+ Self::parse_csv_value(
+ self.options
+ .get(BLOB_DESCRIPTOR_FIELD_OPTION)
+ .or_else(|| self.options.get(BLOB_DESCRIPTOR_FIELD_FALLBACK)),
+ )
}
/// Comma-separated BLOB field names stored as serialized BlobViewStruct
@@ -1816,8 +1823,11 @@ impl<'a> CoreOptions<'a> {
}
fn parse_csv_set(&self, option_name: &'static str) -> HashSet<String> {
- self.options
- .get(option_name)
+ Self::parse_csv_value(self.options.get(option_name))
+ }
+
+ fn parse_csv_value(value: Option<&String>) -> HashSet<String> {
+ value
.map(|s| {
s.split(',')
.map(str::trim)
@@ -3606,6 +3616,34 @@ mod tests {
assert!(!CoreOptions::new(&disabled).blob_view_resolve_enabled());
}
+ #[test]
+ fn blob_descriptor_fallback_obeys_canonical_presence() {
+ for (canonical, expected) in [
+ (None, vec!["a", "b"]),
+ (Some("current"), vec!["current"]),
+ (Some(""), vec![]),
+ (Some(" , "), vec![]),
+ ] {
+ let mut options = HashMap::from([
+ ("blob.stored-descriptor-fields".into(), " a, b,a, ".into()),
+ ("blob-view-field".into(), "view".into()),
+ ]);
+ if let Some(value) = canonical {
+ options.insert("blob-descriptor-field".into(), value.into());
+ }
+ let core = CoreOptions::new(&options);
+ let descriptors = expected
+ .into_iter()
+ .map(String::from)
+ .collect::<HashSet<_>>();
+ assert_eq!(core.blob_descriptor_fields(), descriptors);
+ let mut inline = descriptors;
+ inline.insert("view".into());
+ assert_eq!(core.blob_inline_fields(), inline);
+ assert_eq!(core.blob_fields(), inline);
+ }
+ }
+
#[test]
fn test_validate_scan_options_rejects_unsupported() {
for key in [
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 2ff6bde6..c11ebdfa 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -17,10 +17,10 @@
use crate::spec::core_options::{
first_row_supports_changelog_producer, ChangelogProducer, CoreOptions,
MergeEngine,
- BLOB_DESCRIPTOR_FIELD_OPTION, BLOB_FIELD_OPTION, BLOB_VIEW_FIELD_OPTION,
BUCKET_KEY_OPTION,
- CHANGELOG_PRODUCER_OPTION, INDEX_FILE_IN_DATA_FILE_DIR_OPTION,
POSTPONE_BUCKET,
- QUERY_AUTH_ENABLED_OPTION, SEQUENCE_FIELD_OPTION,
TABLE_READ_SEQUENCE_NUMBER_ENABLED_OPTION,
- TABLE_TYPE_OPTION,
+ BLOB_DESCRIPTOR_FIELD_FALLBACK, BLOB_DESCRIPTOR_FIELD_OPTION,
BLOB_FIELD_OPTION,
+ BLOB_VIEW_FIELD_OPTION, BUCKET_KEY_OPTION, CHANGELOG_PRODUCER_OPTION,
+ INDEX_FILE_IN_DATA_FILE_DIR_OPTION, POSTPONE_BUCKET,
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::{
@@ -516,6 +516,9 @@ impl TableSchema {
message: "Cannot drop all fields in
table".to_string(),
});
}
+ if fields[idx].data_type().is_blob_file_field() {
+ remove_blob_field_options(&mut new_schema.options,
name);
+ }
fields.remove(idx);
// Drop the column's field-scoped aggregation options so no
// orphaned `fields.<col>.*` keys remain (which would
otherwise
@@ -978,10 +981,46 @@ fn match_blob_comment_directive(comment: &str) ->
Option<(&'static str, &'static
}
fn append_csv_option(options: &mut HashMap<String, String>, key: &'static str,
field_name: &str) {
+ // Java ColumnDirectiveUtils.modifyFieldOptions migrates the fallback
+ // before adding a canonical entry so existing descriptor fields survive.
+ if key == BLOB_DESCRIPTOR_FIELD_OPTION &&
options.get(key).is_none_or(String::is_empty) {
+ if let Some(value) = options.remove(BLOB_DESCRIPTOR_FIELD_FALLBACK) {
+ options.insert(key.to_string(), value);
+ }
+ }
let value = append_csv_field(options.get(key).map(String::as_str),
field_name);
options.insert(key.to_string(), value);
}
+fn remove_blob_field_options(options: &mut HashMap<String, String>,
field_name: &str) {
+ for key in [
+ BLOB_FIELD_OPTION,
+ BLOB_DESCRIPTOR_FIELD_OPTION,
+ BLOB_DESCRIPTOR_FIELD_FALLBACK,
+ BLOB_VIEW_FIELD_OPTION,
+ ] {
+ let Some(value) = options.get(key) else {
+ continue;
+ };
+ // An exact empty canonical value disables fallback keys in Java.
+ // Dropping an unrelated column must preserve that override.
+ if value.is_empty() {
+ continue;
+ }
+ let retained = value
+ .split(',')
+ .map(str::trim)
+ .filter(|name| !name.is_empty() && *name != field_name)
+ .collect::<Vec<_>>()
+ .join(",");
+ if retained.is_empty() {
+ options.remove(key);
+ } else {
+ options.insert(key.to_string(), retained);
+ }
+ }
+}
+
fn append_csv_field(existing: Option<&str>, field_name: &str) -> String {
let mut fields = existing
.into_iter()
@@ -3258,7 +3297,11 @@ mod tests {
);
}
- for option in ["blob-descriptor-field", "blob-view-field"] {
+ for option in [
+ "blob-descriptor-field",
+ "blob.stored-descriptor-fields",
+ "blob-view-field",
+ ] {
let result = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column(
@@ -3434,6 +3477,100 @@ mod tests {
);
}
+ #[test]
+ fn blob_descriptor_fallback_survives_column_directives_and_drop() {
+ for canonical in [None, Some(""), Some("thumb")] {
+ let mut builder = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("thumb", DataType::Blob(BlobType::new()))
+ .option("blob.stored-descriptor-fields", "thumb")
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true");
+ if let Some(value) = canonical {
+ builder = builder.option("blob-descriptor-field", value);
+ }
+ let original = TableSchema::new(0, &builder.build().unwrap());
+ let schema = original
+ .apply_changes(vec![crate::spec::SchemaChange::AddColumn {
+ field_names: vec!["preview".into()],
+ data_type: DataType::Blob(BlobType::new()),
+ comment: Some("__BLOB_DESCRIPTOR_FIELD".into()),
+ column_move: None,
+ }])
+ .unwrap();
+ assert_eq!(
+ schema.core_options().blob_descriptor_fields(),
+ HashSet::from(["thumb".into(), "preview".into()])
+ );
+ if canonical != Some("thumb") {
+ assert!(!schema
+ .options()
+ .contains_key("blob.stored-descriptor-fields"));
+ }
+ let schema = schema
+
.apply_changes(vec![crate::spec::SchemaChange::drop_column("thumb".into())])
+ .unwrap();
+ assert_eq!(
+ schema.core_options().blob_descriptor_fields(),
+ HashSet::from(["preview".into()])
+ );
+ assert!(!schema
+ .options()
+ .contains_key("blob.stored-descriptor-fields"));
+ assert_eq!(original.fields()[1].name(), "thumb");
+ assert_eq!(
+ original
+ .options()
+ .get("blob.stored-descriptor-fields")
+ .unwrap(),
+ "thumb"
+ );
+ }
+ }
+
+ #[test]
+ fn blob_descriptor_fallback_promotes_binary_and_rejects_keys() {
+ let builder = || {
+ Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column(
+ "payload",
+ DataType::VarBinary(
+
crate::spec::VarBinaryType::new(crate::spec::VarBinaryType::DEFAULT_LENGTH)
+ .unwrap(),
+ ),
+ )
+ .option("blob.stored-descriptor-fields", "payload")
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ };
+ assert!(matches!(
+ builder().build().unwrap().fields()[1].data_type(),
+ DataType::Blob(_)
+ ));
+ let error = builder().partition_keys(["payload"]).build().unwrap_err();
+ assert!(error.to_string().contains("partition keys"), "{error}");
+ }
+
+ #[test]
+ fn dropping_unrelated_blob_keeps_empty_descriptor_override() {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("keep", DataType::Blob(BlobType::new()))
+ .column("drop", DataType::Blob(BlobType::new()))
+ .option("blob-descriptor-field", "")
+ .option("blob.stored-descriptor-fields", "keep")
+ .option("data-evolution.enabled", "true")
+ .option("row-tracking.enabled", "true")
+ .build()
+ .unwrap();
+ let changed = TableSchema::new(0, &schema)
+
.apply_changes(vec![crate::spec::SchemaChange::drop_column("drop".into())])
+ .unwrap();
+ assert_eq!(changed.options().get("blob-descriptor-field").unwrap(),
"");
+ assert!(changed.core_options().blob_descriptor_fields().is_empty());
+ }
+
#[test]
fn test_blob_comment_directive_rejects_unknown_add_column_directive() {
let schema = Schema::builder()
diff --git a/crates/paimon/src/table/data_evolution_fields.rs
b/crates/paimon/src/table/data_evolution_fields.rs
index 96476d6b..61fc9d33 100644
--- a/crates/paimon/src/table/data_evolution_fields.rs
+++ b/crates/paimon/src/table/data_evolution_fields.rs
@@ -21,7 +21,7 @@
//! of each selected field. A top-level name takes precedence over splitting at
//! a dot, because older schemas can contain literal dotted column names.
-use crate::spec::{DataField, DataType, RowType};
+use crate::spec::{CoreOptions, DataField, DataType, RowType};
use crate::{Error, Result};
use indexmap::IndexMap;
use std::collections::HashSet;
@@ -32,6 +32,36 @@ struct PathSelection {
tails: Vec<String>,
}
+/// Java BaseAppendFileStoreWrite.withWriteType omits a full column list.
+/// With the explicit optimization, it can also omit all non-dedicated fields.
+pub(super) fn can_omit_normal_write_cols(
+ fields: &[DataField],
+ written_columns: &[String],
+ options: &CoreOptions<'_>,
+) -> bool {
+ if written_columns
+ .iter()
+ .map(String::as_str)
+ .eq(fields.iter().map(DataField::name))
+ {
+ return true;
+ }
+ if !options.data_evolution_enabled()
+ || !options.data_evolution_write_cols_optimization_enabled()
+ {
+ return false;
+ }
+ let inline = options.blob_inline_fields();
+ let dedicated_vector = options.vector_file_format().is_some();
+ written_columns.iter().map(String::as_str).eq(fields
+ .iter()
+ .filter(|field| {
+ !(field.data_type().is_blob_file_field() &&
!inline.contains(field.name())
+ || dedicated_vector && matches!(field.data_type(),
DataType::Vector(_)))
+ })
+ .map(DataField::name))
+}
+
pub(super) fn project_by_paths(fields: &[DataField], paths: &[String]) ->
Result<Vec<DataField>> {
let mut selected: IndexMap<String, PathSelection> = IndexMap::new();
for path in paths {
diff --git a/crates/paimon/src/table/data_evolution_reader.rs
b/crates/paimon/src/table/data_evolution_reader.rs
index 3b267121..3e648ca4 100644
--- a/crates/paimon/src/table/data_evolution_reader.rs
+++ b/crates/paimon/src/table/data_evolution_reader.rs
@@ -896,7 +896,12 @@ impl DataEvolutionReader {
Vec::new()
},
true,
- HashSet::new(),
+ // View structs are physically inline even during the unresolved
+ // prescan. They must come from normal Parquet column providers.
+ self.blob_view_fields
+ .union(&self.blob_descriptor_fields)
+ .cloned()
+ .collect(),
HashSet::new(),
false,
None,
@@ -964,7 +969,8 @@ impl DataEvolutionReader {
// whose values would be transformed by resolution.
let read_type = self.wide_file_read_type.clone();
let table_fields = self.table_fields.clone();
- let blob_descriptor_fields = self.blob_descriptor_fields.clone();
+ let mut blob_inline_fields = self.blob_descriptor_fields.clone();
+ blob_inline_fields.extend(self.blob_view_fields.iter().cloned());
let blob_as_descriptor = self.blob_as_descriptor;
let blob_parallelism = self.blob_parallelism;
let batch_size = self.effective_batch_size();
@@ -994,7 +1000,7 @@ impl DataEvolutionReader {
&file_infos,
&read_type,
&table_fields,
- &blob_descriptor_fields,
+ &blob_inline_fields,
row_ranges.is_some(),
nested_enabled,
)?;
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 760f07ba..2dd6697f 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -148,7 +148,9 @@ impl DataEvolutionWriter {
});
}
let partition_keys = schema.partition_keys();
- let blob_descriptor_fields = core_options.blob_descriptor_fields();
+ // Java CoreOptions.updatableBlobFields() includes descriptor and view
+ // fields, both of which store their references in normal data files.
+ let updatable_blob_fields = core_options.blob_inline_fields();
for col in &update_columns {
let top_level =
DataEvolutionPartialWriter::top_level_write_name(col, schema.fields());
if !allow_partition_columns && partition_keys.iter().any(|key| key
== top_level) {
@@ -157,11 +159,11 @@ impl DataEvolutionWriter {
});
}
if let Some(field) = schema.fields().iter().find(|f| f.name() ==
top_level) {
- if field.data_type().is_blob_type() &&
!blob_descriptor_fields.contains(top_level) {
+ if field.data_type().is_blob_type() &&
!updatable_blob_fields.contains(top_level) {
return Err(crate::Error::Unsupported {
message: format!(
"Cannot update raw-data BLOB column '{col}' in
MERGE INTO. \
- Only BLOB columns listed in
'blob-descriptor-field' can be updated"
+ Only inline BLOB columns listed in
'blob-descriptor-field' or 'blob-view-field' can be updated"
),
});
}
@@ -322,10 +324,10 @@ impl DataEvolutionWriter {
// Rewriting an inline Blob column must preserve references for rows
// without an update. Resolving them would replace descriptors with
// payload bytes and unnecessarily require the external files to exist.
- let read_table = index.read_table.copy_with_options(HashMap::from([(
- "blob-as-descriptor".to_string(),
- "true".to_string(),
- )]));
+ let read_table = index.read_table.copy_with_options(HashMap::from([
+ ("blob-as-descriptor".to_string(), "true".to_string()),
+ ("blob-view.resolve.enabled".to_string(), "false".to_string()),
+ ]));
let file_index = &index.files;
if file_index.is_empty() {
return Err(crate::Error::DataInvalid {
@@ -1347,6 +1349,7 @@ struct PartialWriteSet {
file_format: String,
write_fields: Vec<DataField>,
write_columns: Vec<String>,
+ omit_write_cols: bool,
column_indices: Vec<usize>,
schema: Arc<arrow_schema::Schema>,
file_index_options: Option<Arc<FileIndexOptions>>,
@@ -1471,6 +1474,11 @@ impl DataEvolutionPartialWriter {
target_file_size: core_options.target_file_size(),
file_format: core_options.file_format().to_string(),
write_fields: normal_fields,
+ omit_write_cols:
super::data_evolution_fields::can_omit_normal_write_cols(
+ fields,
+ &normal_columns,
+ core_options,
+ ),
write_columns: normal_columns,
column_indices: normal_indices,
schema,
@@ -1487,6 +1495,7 @@ impl DataEvolutionPartialWriter {
file_format: format!("vector.{vector_file_format}"),
write_fields: vector_fields,
write_columns: vector_columns,
+ omit_write_cols: false,
column_indices: vector_indices,
schema,
file_index_options: None,
@@ -1556,7 +1565,7 @@ impl DataEvolutionPartialWriter {
self.format_options.clone(),
Some(0), // file_source: APPEND
Some(first_row_id),
- Some(write_set.write_columns.clone()),
+ (!write_set.omit_write_cols).then(||
write_set.write_columns.clone()),
)?
.with_file_index(write_set.file_index_options.clone());
self.writers.insert(key.clone(), writer);
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 77b24e23..df5cb99c 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -141,7 +141,7 @@ impl AppendDedicatedFormatFileWriter {
}
let normal_schema =
Arc::new(arrow_schema::Schema::new(normal_arrow_fields));
- let normal_field_names = normal_table_fields
+ let normal_field_names: Vec<String> = normal_table_fields
.iter()
.map(|field| field.name().to_string())
.collect();
@@ -180,15 +180,12 @@ impl AppendDedicatedFormatFileWriter {
};
let core_options = CoreOptions::new(format_options);
- // Full append writes include all normal fields in schema order.
Partial
- // column writers must retain their explicit write_cols instead.
- let normal_write_cols = if core_options.data_evolution_enabled()
- && core_options.data_evolution_write_cols_optimization_enabled()
- {
- None
- } else {
- Some(normal_field_names)
- };
+ let normal_write_cols =
(!super::data_evolution_fields::can_omit_normal_write_cols(
+ table_fields,
+ &normal_field_names,
+ &core_options,
+ ))
+ .then_some(normal_field_names);
let normal_index =
super::data_file_index_writer::FileIndexOptions::parse(format_options,
table_fields)?
.and_then(|options|
options.project_to_fields(&normal_table_fields))
diff --git a/crates/paimon/src/table/inline_blob.rs
b/crates/paimon/src/table/inline_blob.rs
index 1cd68c97..0a367fbd 100644
--- a/crates/paimon/src/table/inline_blob.rs
+++ b/crates/paimon/src/table/inline_blob.rs
@@ -26,9 +26,6 @@ pub(super) fn validate_inline_blob_columns(
batch: &RecordBatch,
options: &HashMap<String, String>,
) -> Result<()> {
- if !options.contains_key("blob-descriptor-field") &&
!options.contains_key("blob-view-field") {
- return Ok(());
- }
let options = CoreOptions::new(options);
for (option, fields) in [
("blob-descriptor-field", options.blob_descriptor_fields()),
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 82039211..36e75d58 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -229,7 +229,7 @@ use crate::spec::{
SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_TIMESTAMP_OPTION, SCAN_VERSION_OPTION,
SCAN_WATERMARK_OPTION,
};
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
/// Table represents a table in the catalog.
#[derive(Debug, Clone)]
@@ -252,6 +252,8 @@ pub struct Table {
/// options, so scans don't have to resolve the same selector again.
/// Cleared when [`Table::copy_with_options`] changes the selector.
travel_snapshot: Option<Snapshot>,
+ /// Explicit options remain overrides across later time-travel copies.
+ applied_dynamic_option_keys: HashSet<String>,
}
impl Table {
@@ -277,6 +279,7 @@ impl Table {
rest_env,
time_traveled: false,
travel_snapshot: None,
+ applied_dynamic_option_keys: HashSet::new(),
}
}
@@ -319,6 +322,7 @@ impl Table {
rest_env: None,
time_traveled: false,
travel_snapshot: None,
+ applied_dynamic_option_keys: HashSet::new(),
})
}
@@ -566,6 +570,8 @@ impl Table {
/// [`Table::copy_with_time_travel`] when the options may select a
/// historical snapshot whose schema should be used for reading.
pub fn copy_with_options(&self, extra: HashMap<String, String>) -> Self {
+ let mut applied_dynamic_option_keys =
self.applied_dynamic_option_keys.clone();
+ applied_dynamic_option_keys.extend(extra.keys().cloned());
// Changing the time-travel selector invalidates the resolved snapshot
// (a time-travelled schema then has no matching snapshot anymore, and
// scans of such a copy fail until `copy_with_time_travel` re-resolves
@@ -589,6 +595,7 @@ impl Table {
query_auth_session: self.query_auth_session,
rest_env: self.rest_env.clone(),
time_traveled: self.time_traveled,
+ applied_dynamic_option_keys,
travel_snapshot: if selector_changed {
None
} else {
@@ -623,6 +630,7 @@ impl Table {
travel_snapshot: None,
// Not the schema the catalog loaded, so not a handle it
authorizes.
query_auth_session: None,
+ applied_dynamic_option_keys: HashSet::new(),
..self.clone()
})
}
@@ -636,11 +644,9 @@ impl Table {
pub(crate) async fn copy_with_resolved_snapshot(&self, snapshot:
&Snapshot) -> Result<Self> {
let mut table = self.copy_with_pinned_snapshot(snapshot);
if snapshot.schema_id() != self.schema.id() {
- table.schema = self
- .schema_manager
- .schema(snapshot.schema_id())
- .await?
- .copy_with_replaced_options(table.schema.options().clone());
+ let schema =
self.schema_manager.schema(snapshot.schema_id()).await?;
+ let options = table.historical_field_options(&schema);
+ table.schema = schema.copy_with_replaced_options(options);
}
Ok(table)
}
@@ -684,7 +690,8 @@ impl Table {
/// (`scan.version` / `scan.timestamp-millis` / `scan.timestamp` /
`scan.watermark` /
/// `scan.snapshot-id` / `scan.tag-name`) that resolves to a snapshot, the
/// table's fields and keys come from that snapshot's schema while the
- /// options stay the merged ones (Java `TableSchema.copy(newOptions)`).
+ /// runtime options stay merged. Column declarations follow the historical
+ /// schema unless explicitly overridden, as in Java.
/// Like Java, resolution failures fall back silently to the current
/// schema (the `if let Ok` below swallows them); an invalid selector
/// still fails later at scan planning.
@@ -785,8 +792,8 @@ impl Table {
if let Some(snapshot) = snapshot {
if snapshot.schema_id() != table.schema.id() {
let snapshot_schema =
table.schema_manager.schema(snapshot.schema_id()).await?;
- table.schema =
-
snapshot_schema.copy_with_replaced_options(table.schema.options().clone());
+ let options = table.historical_field_options(&snapshot_schema);
+ table.schema =
snapshot_schema.copy_with_replaced_options(options);
table.time_traveled = true;
}
table.travel_snapshot = Some(snapshot);
@@ -794,6 +801,37 @@ impl Table {
Ok(table)
}
+ fn historical_field_options(&self, historical_schema: &TableSchema) ->
HashMap<String, String> {
+ use crate::spec::{
+ BLOB_DESCRIPTOR_FIELD_FALLBACK, BLOB_DESCRIPTOR_FIELD_OPTION,
BLOB_FIELD_OPTION,
+ BLOB_VIEW_FIELD_OPTION,
+ };
+ let mut options = self.schema.options().clone();
+ // Canonical and fallback keys form one override group in Java
+ // AbstractFileStoreTable.excludeCurrentSchemaFieldOptions.
+ for keys in [
+ &["vector-field"][..],
+ &[BLOB_FIELD_OPTION][..],
+ &[BLOB_DESCRIPTOR_FIELD_OPTION,
BLOB_DESCRIPTOR_FIELD_FALLBACK][..],
+ &[BLOB_VIEW_FIELD_OPTION][..],
+ ] {
+ if keys
+ .iter()
+ .any(|key| self.applied_dynamic_option_keys.contains(*key))
+ {
+ continue;
+ }
+ for key in keys {
+ if let Some(value) = historical_schema.options().get(*key) {
+ options.insert((*key).to_string(), value.clone());
+ } else {
+ options.remove(*key);
+ }
+ }
+ }
+ options
+ }
+
pub async fn copy_with_branch(&self, branch_name: &str) -> Result<Self> {
// The branch schema replaces this one wholesale and could drop the
// declared type, so refuse before any branch I/O.
@@ -858,6 +896,7 @@ impl Table {
rest_env: self.rest_env.clone(),
time_traveled: false,
travel_snapshot: None,
+ applied_dynamic_option_keys: HashSet::new(),
})
}
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index c0d0c38f..a7180132 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1055,6 +1055,70 @@ async fn
test_blob_view_limit_only_resolves_selected_references() {
(3, "Repeated again".to_string(), Some(b"bob".to_vec())),
]
);
+
+ // Partial files force the multi-provider merge and its View prescan.
+ // The invalid, unmatched reference must be copied without resolving it.
+ let replacement = BlobViewStruct::new(source_id, picture_field_id, 0)
+ .serialize()
+ .unwrap();
+ let builder = view.new_write_builder();
+ let update = builder.new_update().unwrap();
+ let batch = RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![0, 1])) as Arc<dyn Array>,
+ ),
+ (
+ "picture",
+ Arc::new(LargeBinaryArray::from(vec![
+ Some(replacement.as_slice()),
+ None,
+ ])) as Arc<dyn Array>,
+ ),
+ ])
+ .unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![batch])
+ .await
+ .unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+
+ let batch = RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![0])) as Arc<dyn Array>,
+ ),
+ (
+ "name",
+ Arc::new(StringArray::from(vec!["Updated"])) as Arc<dyn Array>,
+ ),
+ ])
+ .unwrap();
+ let messages = update
+ .update_by_arrow_with_row_id(vec![batch])
+ .await
+ .unwrap();
+ builder.new_commit().commit(messages).await.unwrap();
+
+ let mut builder = rest_view.new_read_builder();
+ builder.with_limit(3);
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ assert_eq!(
+ collect_blob_rows(&batches),
+ vec![
+ (1, "Updated".to_string(), Some(b"alice".to_vec())),
+ (2, "Repeated".to_string(), None),
+ (3, "Repeated again".to_string(), Some(b"bob".to_vec())),
+ ]
+ );
}
#[cfg(not(windows))]
diff --git a/crates/paimon/tests/table_update_test.rs
b/crates/paimon/tests/table_update_test.rs
index 4ae4323b..ce85b467 100644
--- a/crates/paimon/tests/table_update_test.rs
+++ b/crates/paimon/tests/table_update_test.rs
@@ -44,6 +44,235 @@ async fn evolution_table() -> Table {
table
}
+async fn inline_blob_table(option: &str) -> Table {
+ let path = "memory:/inline_blob_update";
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("payload", DataType::Blob(paimon::spec::BlobType::new()))
+ .column("value", DataType::Int(IntType::new()))
+ .option("row-tracking.enabled", "true")
+ .option("data-evolution.enabled", "true")
+ .option("deletion-vectors.enabled", "true")
+ .option(option, "payload")
+ .build()
+ .unwrap();
+ let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+ setup_dirs(&io, path).await;
+ persist_table_schema(&io, path, table.schema()).await;
+ table
+}
+
+fn inline_reference(option: &str, id: i64) -> Vec<u8> {
+ if option == "blob-view-field" {
+ paimon::spec::BlobViewStruct::new(
+ paimon::catalog::Identifier::new("missing", "upstream"),
+ 5,
+ id,
+ )
+ .serialize()
+ .unwrap()
+ } else {
+ // These files do not exist: staging a partial update must preserve
+ // references without resolving or copying external payloads.
+ paimon::spec::BlobDescriptor::new(format!("memory:/missing/{id}"), 0,
10).serialize()
+ }
+}
+
+fn inline_batch(table: &Table, ids: Vec<i32>, values: &[Option<Vec<u8>>]) ->
RecordBatch {
+ RecordBatch::try_new(
+
paimon::arrow::build_target_arrow_schema(table.schema().fields()).unwrap(),
+ vec![
+ Arc::new(Int32Array::from(ids.clone())),
+ Arc::new(arrow_array::LargeBinaryArray::from_iter(
+ values.iter().map(|value| value.as_deref()),
+ )),
+ Arc::new(Int32Array::from(
+ ids.iter().map(|id| id * 10).collect::<Vec<_>>(),
+ )),
+ ],
+ )
+ .unwrap()
+}
+
+async fn inline_rows(table: &Table) -> Vec<(i32, Option<Vec<u8>>, i32)> {
+ use arrow_array::Array;
+ let read_table = table.copy_with_options(HashMap::from([
+ ("blob-as-descriptor".into(), "true".into()),
+ ("blob-view.resolve.enabled".into(), "false".into()),
+ ]));
+ let builder = read_table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let batches: Vec<RecordBatch> = builder
+ .new_read()
+ .unwrap()
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect()
+ .await
+ .unwrap();
+ let mut rows = Vec::new();
+ for batch in batches {
+ let ids = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ let payloads = batch
+ .column(1)
+ .as_any()
+ .downcast_ref::<arrow_array::LargeBinaryArray>()
+ .unwrap();
+ let values = batch
+ .column(2)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ for row in 0..batch.num_rows() {
+ rows.push((
+ ids.value(row),
+ payloads.is_valid(row).then(|| payloads.value(row).to_vec()),
+ values.value(row),
+ ));
+ }
+ }
+ rows.sort_by_key(|row| row.0);
+ rows
+}
+
+#[tokio::test]
+async fn inline_blob_row_id_updates_keep_unmatched_references_and_nulls() {
+ for option in [
+ "blob-descriptor-field",
+ "blob.stored-descriptor-fields",
+ "blob-view-field",
+ ] {
+ for incremental in [false, true] {
+ let table = inline_blob_table(option).await;
+ let before = vec![
+ Some(inline_reference(option, 0)),
+ None,
+ Some(inline_reference(option, 2)),
+ Some(inline_reference(option, 3)),
+ ];
+ write_batch(&table, &inline_batch(&table, vec![0, 1, 2, 3],
&before)).await;
+ let changed = Some(inline_reference(option, 20));
+ let input = RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![1, 2])) as ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(arrow_array::LargeBinaryArray::from_iter([
+ None,
+ changed.as_deref(),
+ ])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let update = table.new_write_builder().new_update().unwrap();
+ let messages = if incremental {
+ let mut writer = update.new_update_by_row_id().await.unwrap();
+ writer
+ .update_columns(vec![input], vec!["payload".into()])
+ .await
+ .unwrap()
+ } else {
+ update
+ .update_by_arrow_with_row_id(vec![input])
+ .await
+ .unwrap()
+ };
+ assert!(!messages.is_empty());
+ for file in messages.iter().flat_map(|message| &message.new_files)
{
+ assert!(file.file_name.ends_with(".parquet"));
+ assert_eq!(file.write_cols, Some(vec!["payload".into()]));
+ assert_eq!(file.first_row_id, Some(0));
+ assert_eq!(file.row_count, 4);
+ }
+ commit(&table, messages).await;
+ assert_eq!(
+ inline_rows(&table).await,
+ vec![
+ (0, before[0].clone(), 0),
+ (1, None, 10),
+ (2, changed, 20),
+ (3, before[3].clone(), 30),
+ ]
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn inline_blob_upsert_and_delete_preserve_reference_layout() {
+ for option in [
+ "blob-descriptor-field",
+ "blob.stored-descriptor-fields",
+ "blob-view-field",
+ ] {
+ let table = inline_blob_table(option).await;
+ let old = inline_reference(option, 0);
+ write_batch(
+ &table,
+ &inline_batch(&table, vec![0, 1], &[Some(old.clone()), None]),
+ )
+ .await;
+ let new = inline_reference(option, 20);
+ let source = inline_batch(&table, vec![0, 2], &[Some(new.clone()),
None]);
+ let update = table.new_write_builder().new_update().unwrap();
+ let messages = update
+ .upsert_by_arrow_with_key(vec![source], vec!["id".into()])
+ .await
+ .unwrap();
+ assert!(messages
+ .iter()
+ .flat_map(|message| &message.new_files)
+ .all(|file| file.file_name.ends_with(".parquet")));
+ commit(&table, messages).await;
+ assert_eq!(
+ inline_rows(&table).await,
+ vec![(0, Some(new), 0), (1, None, 10), (2, None, 20)]
+ );
+ let update = table.new_write_builder().new_update().unwrap();
+ let messages = update.delete_by_row_id(vec![0, 0]).await.unwrap();
+ commit(&table, messages).await;
+ assert_eq!(
+ inline_rows(&table).await,
+ vec![(1, None, 10), (2, None, 20)]
+ );
+ }
+}
+
+#[tokio::test]
+async fn inline_blob_failed_update_does_not_stage_files() {
+ let table = inline_blob_table("blob-view-field").await;
+ write_batch(&table, &inline_batch(&table, vec![0, 1], &[None,
None])).await;
+ let before = parquet_files(&table).await;
+ let update = table.new_write_builder().new_update().unwrap();
+ let input = RecordBatch::try_from_iter(vec![
+ (
+ "_ROW_ID",
+ Arc::new(Int64Array::from(vec![0, 0])) as ArrayRef,
+ ),
+ (
+ "payload",
+ Arc::new(arrow_array::LargeBinaryArray::from(vec![
+ Some(b"a".as_slice()),
+ Some(b"b".as_slice()),
+ ])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let error = update
+ .update_by_arrow_with_row_id(vec![input])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("duplicate UPDATE operations"));
+ assert_eq!(parquet_files(&table).await, before);
+ assert_eq!(inline_rows(&table).await, vec![(0, None, 0), (1, None, 10)]);
+}
+
fn batch(columns: &[(&str, Vec<i32>)]) -> RecordBatch {
RecordBatch::try_from_iter(columns.iter().map(|(name, values)| {
(
@@ -54,6 +283,223 @@ fn batch(columns: &[(&str, Vec<i32>)]) -> RecordBatch {
.unwrap()
}
+#[tokio::test]
+async fn inline_blob_alias_rejects_malformed_writes_and_updates() {
+ let table = inline_blob_table("blob.stored-descriptor-fields").await;
+ let invalid = b"not a descriptor".to_vec();
+ let mut write = table.new_write_builder().new_write().unwrap();
+ let error = write
+ .write_arrow_batch(&inline_batch(&table, vec![0],
&[Some(invalid.clone())]))
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("serialized BlobDescriptor"),
+ "{error}"
+ );
+ assert!(parquet_files(&table).await.is_empty());
+ write_batch(&table, &inline_batch(&table, vec![0], &[None])).await;
+ let before = parquet_files(&table).await;
+ let input = RecordBatch::try_from_iter(vec![
+ ("_ROW_ID", Arc::new(Int64Array::from(vec![0])) as ArrayRef),
+ (
+ "payload",
+ Arc::new(arrow_array::LargeBinaryArray::from(vec![Some(
+ invalid.as_slice(),
+ )])) as ArrayRef,
+ ),
+ ])
+ .unwrap();
+ let error = table
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![input])
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("serialized BlobDescriptor"),
+ "{error}"
+ );
+ assert_eq!(parquet_files(&table).await, before);
+ assert_eq!(inline_rows(&table).await, vec![(0, None, 0)]);
+}
+
+#[tokio::test]
+async fn normal_column_update_metadata_matches_java_blob_layout() {
+ for option in [
+ "blob-field",
+ "blob-descriptor-field",
+ "blob.stored-descriptor-fields",
+ "blob-view-field",
+ ] {
+ for optimize in [false, true] {
+ let table = inline_blob_table(option)
+ .await
+ .copy_with_options(HashMap::from([
+ (
+
"data-evolution.write-cols-optimization.enabled".into(),
+ optimize.to_string(),
+ ),
+ ("metadata.stats-mode".into(), "full".into()),
+ ]));
+ persist_table_schema(table.file_io(), table.location(),
table.schema()).await;
+ let reference = if option == "blob-field" {
+ b"raw payload".to_vec()
+ } else {
+ inline_reference(option, 0)
+ };
+ write_batch(&table, &inline_batch(&table, vec![0],
&[Some(reference)])).await;
+ let original = inline_rows(&table).await[0].1.clone();
+ let messages = table
+ .new_write_builder()
+ .new_update()
+ .unwrap()
+ .update_by_arrow_with_row_id(vec![matched(
+ vec![0],
+ &[("id", vec![1]), ("value", vec![100])],
+ )])
+ .await
+ .unwrap();
+ let file = &messages[0].new_files[0];
+ let expected = if optimize && option == "blob-field" {
+ None
+ } else {
+ Some(vec!["id".to_string(), "value".to_string()])
+ };
+ assert_eq!(file.write_cols, expected);
+ assert_eq!(file.first_row_id, Some(0));
+ commit(&table, messages).await;
+ assert_eq!(inline_rows(&table).await, vec![(1, original, 100)]);
+ }
+ }
+}
+
+#[tokio::test]
+async fn inline_blob_history_restores_layout_after_drop_and_add() {
+ for option in [
+ "blob-descriptor-field",
+ "blob.stored-descriptor-fields",
+ "blob-view-field",
+ ] {
+ let table = inline_blob_table(option).await;
+ let reference = inline_reference(option, 0);
+ write_batch(
+ &table,
+ &inline_batch(&table, vec![0, 1], &[Some(reference.clone()),
None]),
+ )
+ .await;
+ let update = table.new_write_builder().new_update().unwrap();
+ commit(
+ &table,
+ update
+ .update_by_arrow_with_row_id(vec![matched(vec![0], &[("value",
vec![100])])])
+ .await
+ .unwrap(),
+ )
+ .await;
+ let expected = inline_rows(&table).await;
+ let schema = table
+ .schema()
+ .apply_changes(vec![
+ paimon::spec::SchemaChange::drop_column("payload".into()),
+ paimon::spec::SchemaChange::AddColumn {
+ field_names: vec!["reference".into()],
+ data_type: DataType::Blob(paimon::spec::BlobType::new()),
+ comment: Some("__BLOB_DESCRIPTOR_FIELD".into()),
+ column_move: None,
+ },
+ ])
+ .unwrap();
+ persist_table_schema(table.file_io(), table.location(), &schema).await;
+ let current = table.copy_with_resolved_schema(schema, "main").unwrap();
+ let historical = current
+ .copy_with_time_travel_strict(HashMap::from([
+ ("scan.snapshot-id".into(), "2".into()),
+ ("read.batch-size".into(), "1".into()),
+ ]))
+ .await
+ .unwrap();
+ assert_eq!(
+ historical.schema().options().get(option).unwrap(),
+ "payload"
+ );
+ assert_eq!(inline_rows(&historical).await, expected);
+ if option == "blob.stored-descriptor-fields" {
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ table
+ .tag_manager()
+ .create("before_drop", &snapshot)
+ .await
+ .unwrap();
+ let branches =
+ paimon::table::BranchManager::new(table.file_io().clone(),
table.location().into());
+ branches
+ .create_branch_from_tag("dev", "before_drop")
+ .await
+ .unwrap();
+ persist_table_schema(
+ table.file_io(),
+ &branches.branch_path("dev"),
+ current.schema(),
+ )
+ .await;
+ let overridden = table
+
.copy_with_options(HashMap::from([("blob-descriptor-field".into(),
"".into())]));
+ let branch = overridden.copy_with_branch("dev").await.unwrap();
+ let history = branch
+ .copy_with_time_travel_strict(HashMap::from([(
+ "scan.snapshot-id".into(),
+ "2".into(),
+ )]))
+ .await
+ .unwrap();
+ assert_eq!(inline_rows(&history).await, expected);
+ let resolved = overridden
+ .copy_with_resolved_schema(current.schema().clone(), "main")
+ .unwrap();
+ let history = resolved
+ .copy_with_time_travel_strict(HashMap::from([(
+ "scan.snapshot-id".into(),
+ "2".into(),
+ )]))
+ .await
+ .unwrap();
+ assert_eq!(inline_rows(&history).await, expected);
+ }
+ // An explicit alias override keeps the canonical/fallback group from
+ // being restored, including across successive copies.
+ let overridden = current
+ .copy_with_options(HashMap::from([(
+ "blob.stored-descriptor-fields".into(),
+ "".into(),
+ )]))
+
.copy_with_time_travel_strict(HashMap::from([("scan.snapshot-id".into(),
"2".into())]))
+ .await
+ .unwrap();
+ assert_eq!(
+ overridden
+ .schema()
+ .options()
+ .get("blob-descriptor-field")
+ .unwrap(),
+ "reference"
+ );
+ assert_eq!(
+ overridden
+ .schema()
+ .options()
+ .get("blob.stored-descriptor-fields")
+ .unwrap(),
+ ""
+ );
+ }
+}
+
fn matched(row_ids: Vec<i64>, columns: &[(&str, Vec<i32>)]) -> RecordBatch {
let input = batch(columns);
let mut named = vec![("_ROW_ID", Arc::new(Int64Array::from(row_ids)) as
ArrayRef)];