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 8c3527be Align row-tracking commit metadata and conflicts with Java
(#1029)
8c3527be is described below
commit 8c3527be61884d0ebee35a7491c020d25f796d0b
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 4 22:26:45 2026 +0800
Align row-tracking commit metadata and conflicts with Java (#1029)
---
crates/paimon/src/spec/core_options.rs | 15 +
crates/paimon/src/table/table_commit.rs | 171 +++----
.../paimon/src/table/table_commit/row_tracking.rs | 141 ++++++
.../src/table/table_commit/row_tracking_tests.rs | 564 +++++++++++++++++++++
4 files changed, 783 insertions(+), 108 deletions(-)
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index 8aabb1d3..1775b5b7 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -85,6 +85,8 @@ const DEFAULT_METADATA_STATS_KEEP_FIRST_N_COLUMNS: i32 = -1;
const FIELDS_PREFIX: &str = "fields";
const STATS_MODE_SUFFIX: &str = "stats-mode";
const ROW_TRACKING_ENABLED_OPTION: &str = "row-tracking.enabled";
+const ROW_TRACKING_PARTITION_GROUP_ON_COMMIT_OPTION: &str =
+ "row-tracking.partition-group-on-commit";
const CLUSTERING_INCREMENTAL_OPTION: &str = "clustering.incremental";
pub(crate) const TABLE_TYPE_OPTION: &str = "type";
@@ -218,6 +220,9 @@ pub enum ChangelogProducer {
pub enum GlobalIndexColumnUpdateAction {
ThrowError,
DropPartitionIndex,
+ /// Preserve existing indexes. As in Java, the caller must refresh affected
+ /// index ranges separately before relying on their updated values.
+ Ignore,
}
/// Search mode for global index queries.
@@ -1130,6 +1135,7 @@ impl<'a> CoreOptions<'a> {
{
"THROW_ERROR" => Ok(GlobalIndexColumnUpdateAction::ThrowError),
"DROP_PARTITION_INDEX" =>
Ok(GlobalIndexColumnUpdateAction::DropPartitionIndex),
+ "IGNORE" => Ok(GlobalIndexColumnUpdateAction::Ignore),
other => Err(crate::Error::ConfigInvalid {
message: format!("Unsupported
global-index.column-update-action: {other}"),
}),
@@ -1353,6 +1359,15 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(false)
}
+ /// Group new file metadata by partition before assigning row IDs. Defaults
+ /// to true, matching Java, so each partition receives contiguous row IDs.
+ pub fn row_tracking_partition_group_on_commit(&self) -> bool {
+ self.options
+ .get(ROW_TRACKING_PARTITION_GROUP_ON_COMMIT_OPTION)
+ .map(|v| v.eq_ignore_ascii_case("true"))
+ .unwrap_or(true)
+ }
+
/// Whether incremental clustering is enabled. Default is false.
pub fn clustering_incremental_enabled(&self) -> bool {
self.options
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 9b2f48e9..78f17710 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -47,6 +47,9 @@ use std::time::{SystemTime, UNIX_EPOCH};
const BATCH_COMMIT_IDENTIFIER: i64 = i64::MAX;
/// Java RollingFileWriter.CHECK_ROLLING_RECORD_CNT.
const CHECK_ROLLING_RECORD_COUNT: usize = 1000;
+
+mod row_tracking;
+
const DELETION_VECTORS_INDEX_TYPE: &str = "DELETION_VECTORS";
type PartitionBucketKey = (Vec<u8>, i32);
@@ -1208,10 +1211,16 @@ impl TableCommit {
.and_then(|s| s.next_row_id())
.or(Some(first_row_id_start));
} else {
- let (assigned, nrid) = self.assign_row_tracking_meta(
+ let mut entries = std::mem::take(&mut resolved.entries);
+ if CoreOptions::new(self.table.schema().options())
+ .row_tracking_partition_group_on_commit()
+ {
+ entries = row_tracking::group_by_partition(entries);
+ }
+ let (assigned, nrid) = row_tracking::assign_row_tracking(
new_snapshot_id,
first_row_id_start,
- std::mem::take(&mut resolved.entries),
+ entries,
)?;
resolved.entries = assigned;
next_row_id = Some(nrid);
@@ -1998,6 +2007,7 @@ impl TableCommit {
}
match
CoreOptions::new(self.table.schema().options()).global_index_column_update_action()?
{
+ GlobalIndexColumnUpdateAction::Ignore => Ok(vec![]),
GlobalIndexColumnUpdateAction::DropPartitionIndex => Ok(affected
.into_iter()
.map(|entry| IndexManifestEntry {
@@ -2648,7 +2658,10 @@ impl TableCommit {
let mut existing_index: HashSet<(Vec<u8>, i32, i64, i64)> =
HashSet::new();
let mut existing_ranges: ExistingRowIdRanges = HashMap::new();
- for base in base_entries {
+ for base in base_entries
+ .iter()
+ .filter(|entry| !is_dedicated_storage_file(entry.file()))
+ {
if let Some(first_row_id) = base.file().first_row_id {
existing_index.insert((
base.partition().to_vec(),
@@ -2656,12 +2669,10 @@ impl TableCommit {
first_row_id,
base.file().row_count,
));
- if !is_dedicated_storage_file(base.file()) {
- existing_ranges
- .entry((base.partition().to_vec(), base.bucket()))
- .or_default()
- .push((first_row_id, first_row_id +
base.file().row_count - 1));
- }
+ existing_ranges
+ .entry((base.partition().to_vec(), base.bucket()))
+ .or_default()
+ .push((first_row_id, first_row_id + base.file().row_count
- 1));
}
}
@@ -2669,14 +2680,14 @@ impl TableCommit {
let first_row_id = entry.file().first_row_id.unwrap();
if is_dedicated_storage_file(entry.file()) {
if let Some((start, end)) = entry.file().row_id_range() {
- let overlaps_existing = existing_ranges
+ let covered_by_existing = existing_ranges
.get(&(entry.partition().to_vec(), entry.bucket()))
.is_some_and(|ranges| {
ranges.iter().any(|&(base_start, base_end)| {
- ranges_overlap(start, end, base_start,
base_end)
+ base_start <= start && end <= base_end
})
});
- if overlaps_existing {
+ if covered_by_existing {
continue;
}
}
@@ -2746,6 +2757,43 @@ impl TableCommit {
}
}
}
+ Self::check_dedicated_row_id_ranges(&entries, commit_entries)
+ }
+
+ /// Dedicated files can roll independently, but each must fit in one normal
+ /// file range. Adjacent ranges must not be merged: the DE reader joins one
+ /// normal group at a time, matching Java's RowRangeIndex with
mergeAdjacent=false.
+ fn check_dedicated_row_id_ranges(
+ normal_entries: &[&ManifestEntry],
+ entries: &[ManifestEntry],
+ ) -> Result<()> {
+ for entry in entries
+ .iter()
+ .filter(|entry| is_dedicated_storage_file(entry.file()))
+ {
+ let Some((start, end)) = entry.file().row_id_range() else {
+ continue;
+ };
+ let covered = normal_entries.iter().any(|normal| {
+ normal.partition() == entry.partition()
+ && normal.bucket() == entry.bucket()
+ && normal
+ .file()
+ .row_id_range()
+ .is_some_and(|(base_start, base_end)| {
+ base_start <= start && end <= base_end
+ })
+ });
+ if !covered {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "For Data Evolution table, dedicated file '{}'
[{start}, {end}] is not covered by one data file range.",
+ entry.file().file_name,
+ ),
+ source: None,
+ });
+ }
+ }
Ok(())
}
@@ -2797,9 +2845,8 @@ impl TableCommit {
for entry in self
.read_delta_entries(partition_filter.as_ref(), &snapshot)
.await?
- .into_iter()
- .filter(|entry| *entry.kind() == FileKind::Add)
{
+ // Java's DML checker treats deleted column ranges as writes
too.
let Some((start, end)) = entry.file().row_id_range() else {
continue;
};
@@ -2829,10 +2876,7 @@ impl TableCommit {
delta_entries: &[ManifestEntry],
) -> Result<Vec<RowIdWriteRange>> {
let mut ranges = Vec::new();
- for entry in delta_entries
- .iter()
- .filter(|entry| *entry.kind() == FileKind::Add)
- {
+ for entry in delta_entries {
let Some((start, end)) = entry.file().row_id_range() else {
continue;
};
@@ -2901,96 +2945,6 @@ impl TableCommit {
Ok(ids)
}
- /// Assign row tracking metadata: snapshot ID as sequence number, and
- /// first_row_id for new APPEND files that don't already have one.
- /// Normal files advance the main counter. Blob files (identified by file
name)
- /// use per-column counters starting from the same base, since each blob
column
- /// rolls independently.
- fn assign_row_tracking_meta(
- &self,
- snapshot_id: i64,
- first_row_id_start: i64,
- entries: Vec<ManifestEntry>,
- ) -> Result<(Vec<ManifestEntry>, i64)> {
- let mut result = Vec::with_capacity(entries.len());
- let mut start = first_row_id_start;
- let mut blob_start_default = first_row_id_start;
- let mut blob_starts: HashMap<String, i64> = HashMap::new();
- let mut vector_store_start = first_row_id_start;
-
- for entry in entries {
- let mut entry = entry.with_sequence_number(snapshot_id,
snapshot_id);
- if entry.file().file_source.is_none() {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "file_source must be present for row-tracking table,
file={}",
- entry.file().file_name
- ),
- source: None,
- });
- }
- let contains_row_id =
- entry.file().write_cols.as_ref().is_some_and(|cols| {
- cols.iter().any(|col| col ==
crate::spec::ROW_ID_FIELD_NAME)
- });
- if *entry.kind() == FileKind::Add
- && entry.file().file_source == Some(0) // APPEND
- && entry.file().first_row_id.is_none()
- && !contains_row_id
- {
- if is_blob_data_file(entry.file()) {
- let blob_field_name = entry
- .file()
- .write_cols
- .as_ref()
- .and_then(|cols| cols.first())
- .cloned()
- .ok_or_else(|| crate::Error::DataInvalid {
- message: format!(
- "Blob file '{}' must have write_cols for
row-tracking assignment.",
- entry.file().file_name
- ),
- source: None,
- })?;
- let blob_start = blob_starts
- .entry(blob_field_name)
- .or_insert(blob_start_default);
- if *blob_start >= start {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "This is a bug, blobStart {} should be less
than start {} when assigning a blob entry file.",
- *blob_start, start
- ),
- source: None,
- });
- }
- entry = entry.with_first_row_id(*blob_start);
- *blob_start += entry.file().row_count;
- } else if is_vector_store_file(entry.file()) {
- if vector_store_start >= start {
- return Err(crate::Error::DataInvalid {
- message: format!(
- "This is a bug, vectorStoreStart {} should be
less than start {} when assigning a vector-store entry file.",
- vector_store_start, start
- ),
- source: None,
- });
- }
- entry = entry.with_first_row_id(vector_store_start);
- vector_store_start += entry.file().row_count;
- } else {
- entry = entry.with_first_row_id(start);
- blob_start_default = start;
- blob_starts.clear();
- start += entry.file().row_count;
- }
- }
- result.push(entry);
- }
-
- Ok((result, start))
- }
-
/// Validate that files with pre-assigned `first_row_id` (e.g.
partial-column
/// files from MERGE INTO) still match existing files in the current
snapshot.
///
@@ -3651,6 +3605,7 @@ mod tests {
use super::*;
include!("table_commit/parity_tests.rs");
include!("table_commit/recovery_tests.rs");
+ include!("table_commit/row_tracking_tests.rs");
}
#[tokio::test]
diff --git a/crates/paimon/src/table/table_commit/row_tracking.rs
b/crates/paimon/src/table/table_commit/row_tracking.rs
new file mode 100644
index 00000000..45dacf99
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/row_tracking.rs
@@ -0,0 +1,141 @@
+// 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.
+
+//! Row tracking metadata assignment, following Java RowTrackingCommitUtils.
+
+use std::collections::HashMap;
+
+use super::{is_blob_data_file, is_vector_store_file};
+use crate::spec::{FileKind, ManifestEntry};
+use crate::Result;
+
+/// Group partitions without changing the file order within a partition. This
+/// keeps each partition's new row IDs contiguous and preserves Blob alignment.
+pub(super) fn group_by_partition(entries: Vec<ManifestEntry>) ->
Vec<ManifestEntry> {
+ let mut partitions = indexmap::IndexMap::<Vec<u8>,
Vec<ManifestEntry>>::new();
+ for entry in entries {
+ partitions
+ .entry(entry.partition().to_vec())
+ .or_default()
+ .push(entry);
+ }
+ partitions.into_values().flatten().collect()
+}
+
+/// Zero is the pending-snapshot sentinel. A rewritten file can contain both
+/// unmodified and modified records, so its minimum must survive the commit.
+fn assign_snapshot_id(snapshot_id: i64, entry: ManifestEntry) -> ManifestEntry
{
+ let min = entry.file().min_sequence_number;
+ let max = entry.file().max_sequence_number;
+ if min == 0 {
+ entry.with_sequence_number(snapshot_id, snapshot_id)
+ } else if max == 0 {
+ entry.with_sequence_number(min, snapshot_id)
+ } else {
+ entry
+ }
+}
+
+/// Assign row tracking metadata: snapshot ID as sequence number, and
+/// first_row_id for new APPEND files that don't already have one.
+/// Normal files advance the main counter. Blob files (identified by file name)
+/// use per-column counters starting from the same base, since each blob column
+/// rolls independently.
+pub(super) fn assign_row_tracking(
+ snapshot_id: i64,
+ first_row_id_start: i64,
+ entries: Vec<ManifestEntry>,
+) -> Result<(Vec<ManifestEntry>, i64)> {
+ let mut result = Vec::with_capacity(entries.len());
+ let mut start = first_row_id_start;
+ let mut blob_start_default = first_row_id_start;
+ let mut blob_starts: HashMap<String, i64> = HashMap::new();
+ let mut vector_store_start = first_row_id_start;
+
+ for entry in entries {
+ let mut entry = assign_snapshot_id(snapshot_id, entry);
+ if entry.file().file_source.is_none() {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "file_source must be present for row-tracking table,
file={}",
+ entry.file().file_name
+ ),
+ source: None,
+ });
+ }
+ let contains_row_id = entry
+ .file()
+ .write_cols
+ .as_ref()
+ .is_some_and(|cols| cols.iter().any(|col| col ==
crate::spec::ROW_ID_FIELD_NAME));
+ if *entry.kind() == FileKind::Add
+ && entry.file().file_source == Some(0) // APPEND
+ && entry.file().first_row_id.is_none()
+ && !contains_row_id
+ {
+ if is_blob_data_file(entry.file()) {
+ let blob_field_name = entry
+ .file()
+ .write_cols
+ .as_ref()
+ .and_then(|cols| cols.first())
+ .cloned()
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: format!(
+ "Blob file '{}' must have write_cols for
row-tracking assignment.",
+ entry.file().file_name
+ ),
+ source: None,
+ })?;
+ let blob_start = blob_starts
+ .entry(blob_field_name)
+ .or_insert(blob_start_default);
+ if *blob_start >= start {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "This is a bug, blobStart {} should be less than
start {} when assigning a blob entry file.",
+ *blob_start, start
+ ),
+ source: None,
+ });
+ }
+ entry = entry.with_first_row_id(*blob_start);
+ *blob_start += entry.file().row_count;
+ } else if is_vector_store_file(entry.file()) {
+ if vector_store_start >= start {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "This is a bug, vectorStoreStart {} should be less
than start {} when assigning a vector-store entry file.",
+ vector_store_start, start
+ ),
+ source: None,
+ });
+ }
+ entry = entry.with_first_row_id(vector_store_start);
+ vector_store_start += entry.file().row_count;
+ } else {
+ entry = entry.with_first_row_id(start);
+ blob_start_default = start;
+ blob_starts.clear();
+ start += entry.file().row_count;
+ }
+ }
+ result.push(entry);
+ }
+
+ Ok((result, start))
+}
diff --git a/crates/paimon/src/table/table_commit/row_tracking_tests.rs
b/crates/paimon/src/table/table_commit/row_tracking_tests.rs
new file mode 100644
index 00000000..611d023d
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/row_tracking_tests.rs
@@ -0,0 +1,564 @@
+// 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.
+
+// Persisted metadata must follow Java RowTrackingCommitUtils, including
entries
+// written by other engines with sequence numbers that are already assigned.
+#[tokio::test]
+async fn row_tracking_sequence_assignment_preserves_existing_versions() {
+ let io = test_file_io();
+ let path = "memory:/row-tracking-sequences";
+ setup_dirs(&io, path).await;
+ let commit = setup_data_evolution_commit(&io, path);
+ let mut initial = test_data_file("initial.parquet", 10);
+ initial.file_source = Some(0);
+ commit
+ .commit(vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ vec![initial],
+ )])
+ .await
+ .unwrap();
+
+ for (index, min, max, expected_min, expected_max) in [
+ (2, 0, 0, 2, 2),
+ (3, 1, 0, 1, 3),
+ (4, 1, 2, 1, 2),
+ (5, 0, 2, 5, 5),
+ ] {
+ let mut file = test_data_file(&format!("partial-{index}.parquet"), 10);
+ file.file_source = Some(0);
+ file.first_row_id = Some(0);
+ file.write_cols = Some(vec!["name".into()]);
+ file.min_sequence_number = min;
+ file.max_sequence_number = max;
+ file.column_max_sequence_numbers = Some(vec![max]);
+ let mut message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![file]);
+ message.check_from_snapshot = Some(index - 1);
+ commit.commit(vec![message]).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.id(), index);
+ assert_eq!(snapshot.next_row_id(), Some(10));
+ let entries = commit.read_delta_entries(None,
&snapshot).await.unwrap();
+ let file = entries[0].file();
+ assert_eq!(
+ (file.min_sequence_number, file.max_sequence_number),
+ (expected_min, expected_max)
+ );
+ assert_eq!(file.first_row_id, Some(0));
+ assert_eq!(file.column_max_sequence_numbers, Some(vec![max]));
+ }
+}
+
+#[tokio::test]
+async fn row_tracking_overwrite_retains_deleted_file_sequences() {
+ let io = test_file_io();
+ let path = "memory:/row-tracking-overwrite-sequences";
+ setup_dirs(&io, path).await;
+ let commit = setup_row_tracking_commit(&io, path);
+ let mut file = test_data_file("initial.parquet", 10);
+ file.file_source = Some(0);
+ commit
+ .commit(vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ vec![file],
+ )])
+ .await
+ .unwrap();
+ let mut replacement = test_data_file("replacement.parquet", 4);
+ replacement.file_source = Some(0);
+ commit
+ .overwrite(
+ vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ vec![replacement],
+ )],
+ None,
+ )
+ .await
+ .unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.next_row_id(), Some(14));
+ let entries = commit.read_delta_entries(None, &snapshot).await.unwrap();
+ assert_eq!(entries.len(), 2);
+ for entry in entries {
+ let expected = if *entry.kind() == FileKind::Delete {
+ (1, 0)
+ } else {
+ (2, 10)
+ };
+ assert_eq!(entry.file().min_sequence_number, expected.0);
+ assert_eq!(entry.file().max_sequence_number, expected.0);
+ assert_eq!(entry.file().first_row_id, Some(expected.1));
+ }
+}
+
+#[tokio::test]
+async fn row_tracking_partition_grouping_follows_java_option() {
+ for enabled in [None, Some(true), Some(false)] {
+ let io = test_file_io();
+ let path = format!("memory:/row-tracking-partition-{enabled:?}");
+ setup_dirs(&io, &path).await;
+ let mut options = HashMap::from([("row-tracking.enabled".to_string(),
"true".to_string())]);
+ if let Some(enabled) = enabled {
+ options.insert(
+ "row-tracking.partition-group-on-commit".into(),
+ enabled.to_string(),
+ );
+ }
+ let table = test_partitioned_table(&io,
&path).copy_with_options(options);
+ let commit = TableCommit::new(table, "test-user".into());
+ let messages = [("a", "a-1", 2), ("b", "b-1", 3), ("a", "a-2", 4)]
+ .into_iter()
+ .map(|(partition, name, count)| {
+ let mut file = test_data_file(&format!("{name}.parquet"),
count);
+ file.file_source = Some(0);
+ CommitMessage::new(partition_bytes(partition), 0, vec![file])
+ })
+ .collect();
+ commit.commit(messages).await.unwrap();
+ let snapshot = latest_snapshot(&io, &path).await.unwrap();
+ assert_eq!(snapshot.next_row_id(), Some(9));
+ let entries = commit.read_delta_entries(None,
&snapshot).await.unwrap();
+ let actual: Vec<_> = entries
+ .iter()
+ .map(|e| (e.file().file_name.as_str(), e.file().first_row_id))
+ .collect();
+ let expected = if enabled == Some(false) {
+ vec![
+ ("a-1.parquet", Some(0)),
+ ("b-1.parquet", Some(2)),
+ ("a-2.parquet", Some(5)),
+ ]
+ } else {
+ vec![
+ ("a-1.parquet", Some(0)),
+ ("a-2.parquet", Some(2)),
+ ("b-1.parquet", Some(6)),
+ ]
+ };
+ assert_eq!(actual, expected, "grouping={enabled:?}");
+ }
+}
+
+#[test]
+fn row_tracking_groups_preserve_independently_rolled_blob_columns() {
+ fn entry(partition: &str, name: &str, rows: i64, column: Option<&str>) ->
ManifestEntry {
+ let mut file = test_data_file(name, rows);
+ file.file_source = Some(0);
+ file.write_cols = column.map(|name| vec![name.to_string()]);
+ ManifestEntry::new(FileKind::Add, partition_bytes(partition), 0, -1,
file, 2)
+ }
+ // Writers can interleave partitions while each Blob column rolls at a
+ // different size. Grouping must retain normal-file / Blob-file order.
+ let entries = vec![
+ entry("a", "a-0.parquet", 5, None),
+ entry("a", "a-left-0.blob", 2, Some("left")),
+ entry("a", "a-left-1.blob", 3, Some("left")),
+ entry("a", "a-right-0.blob", 5, Some("right")),
+ entry("b", "b-0.parquet", 2, None),
+ entry("b", "b-left-0.blob", 2, Some("left")),
+ entry("a", "a-1.parquet", 3, None),
+ entry("a", "a-left-2.blob", 3, Some("left")),
+ entry("a", "a-right-1.blob", 1, Some("right")),
+ entry("a", "a-right-2.blob", 2, Some("right")),
+ ];
+ let (entries, next) =
+ row_tracking::assign_row_tracking(7, 10,
row_tracking::group_by_partition(entries))
+ .unwrap();
+ assert_eq!(next, 20);
+ let row_ids: Vec<_> = entries
+ .iter()
+ .map(|entry| (entry.file().file_name.as_str(),
entry.file().first_row_id))
+ .collect();
+ assert_eq!(
+ row_ids,
+ vec![
+ ("a-0.parquet", Some(10)),
+ ("a-left-0.blob", Some(10)),
+ ("a-left-1.blob", Some(12)),
+ ("a-right-0.blob", Some(10)),
+ ("a-1.parquet", Some(15)),
+ ("a-left-2.blob", Some(15)),
+ ("a-right-1.blob", Some(15)),
+ ("a-right-2.blob", Some(16)),
+ ("b-0.parquet", Some(18)),
+ ("b-left-0.blob", Some(18)),
+ ]
+ );
+ assert!(entries.iter().all(
+ |entry| entry.file().min_sequence_number == 7 &&
entry.file().max_sequence_number == 7
+ ));
+}
+
+#[test]
+fn row_tracking_keeps_explicit_row_ids_and_completed_rewrite_metadata() {
+ let mut embedded = test_data_file("embedded.parquet", 3);
+ embedded.file_source = Some(0);
+ embedded.write_cols = Some(vec!["id".into(),
crate::spec::ROW_ID_FIELD_NAME.into()]);
+ embedded.min_sequence_number = 2;
+ embedded.max_sequence_number = 0;
+ let mut rewritten = test_data_file("rewritten.parquet", 6);
+ rewritten.file_source = Some(1);
+ rewritten.first_row_id = Some(20);
+ rewritten.min_sequence_number = 2;
+ rewritten.max_sequence_number = 4;
+ let entries = [embedded, rewritten]
+ .into_iter()
+ .map(|file| ManifestEntry::new(FileKind::Add,
EMPTY_SERIALIZED_ROW.clone(), 0, -1, file, 2))
+ .collect();
+ let (entries, next) = row_tracking::assign_row_tracking(9, 100,
entries).unwrap();
+ assert_eq!(next, 100);
+ assert_eq!(entries[0].file().first_row_id, None);
+ assert_eq!(entries[0].file().min_sequence_number, 2);
+ assert_eq!(entries[0].file().max_sequence_number, 9);
+ assert_eq!(entries[1].file().first_row_id, Some(20));
+ assert_eq!(entries[1].file().min_sequence_number, 2);
+ assert_eq!(entries[1].file().max_sequence_number, 4);
+}
+
+struct ConcurrentRowTrackingAppend {
+ table: Table,
+ attempts: std::sync::Mutex<Vec<Snapshot>>,
+}
+
+#[async_trait::async_trait]
+impl SnapshotCommit for ConcurrentRowTrackingAppend {
+ async fn commit(
+ &self,
+ _: Option<&str>,
+ snapshot: &Snapshot,
+ _: &[PartitionStatistics],
+ ) -> Result<bool> {
+ let first = {
+ let mut attempts = self.attempts.lock().unwrap();
+ attempts.push(snapshot.clone());
+ attempts.len() == 1
+ };
+ if first {
+ let mut file = test_data_file("concurrent.parquet", 3);
+ file.file_source = Some(0);
+ TableCommit::new(self.table.clone(), "other".into())
+ .commit(vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ vec![file],
+ )])
+ .await?;
+ return Ok(false);
+ }
+ self.table
+ .snapshot_manager()
+ .commit_snapshot(snapshot)
+ .await
+ }
+}
+
+#[tokio::test]
+async fn row_tracking_retry_reassigns_unpublished_ids_and_is_idempotent() {
+ let io = test_file_io();
+ let path = "memory:/row-tracking-retry";
+ setup_dirs(&io, path).await;
+ let table = test_data_evolution_table(&io,
path).copy_with_options(HashMap::from([
+ ("manifest.sidecar.enabled".into(), "true".into()),
+ ("manifest.target-file-size".into(), "1 B".into()),
+ ]));
+ let mut commit = TableCommit::new(table.clone(), "job".into());
+ commit.commit_min_retry_wait_ms = 0;
+ commit.commit_max_retry_wait_ms = 0;
+ let publisher = Arc::new(ConcurrentRowTrackingAppend {
+ table,
+ attempts: std::sync::Mutex::new(Vec::new()),
+ });
+ commit.snapshot_commit = publisher.clone();
+ let mut file = test_data_file("pending.parquet", 4);
+ file.file_source = Some(0);
+ let message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![file]);
+ commit
+ .commit_with_identifier(vec![message.clone()], 42)
+ .await
+ .unwrap();
+ let attempts = publisher.attempts.lock().unwrap().clone();
+ assert_eq!(
+ attempts
+ .iter()
+ .map(|s| (s.id(), s.next_row_id()))
+ .collect::<Vec<_>>(),
+ vec![(1, Some(4)), (2, Some(7))]
+ );
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.next_row_id(), Some(7));
+ let entries = commit.read_delta_entries(None, &snapshot).await.unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].file().first_row_id, Some(3));
+ assert_eq!(entries[0].file().min_sequence_number, 2);
+ assert_eq!(entries[0].file().max_sequence_number, 2);
+ let before = manifest_paths(&io, path).await;
+ commit
+ .filter_and_commit_with_identifier(vec![message], 42)
+ .await
+ .unwrap();
+ assert_eq!(publisher.attempts.lock().unwrap().len(), 2);
+ assert_eq!(manifest_paths(&io, path).await, before);
+ assert_eq!(
+ latest_snapshot(&io, path).await.unwrap().next_row_id(),
+ Some(7)
+ );
+}
+
+#[tokio::test]
+async fn row_tracking_dedicated_updates_require_one_complete_normal_range() {
+ // The latest snapshot may contain two adjacent normal ranges after another
+ // engine rewrites a former [0, 9] file. A stale Blob must not span both.
+ for extension in ["blob", "vector.vortex"] {
+ for (start, count, valid) in [
+ (1, 3, true),
+ (0, 5, true),
+ (0, 10, false),
+ (4, 2, false),
+ (9, 2, false),
+ ] {
+ let io = test_file_io();
+ let path =
format!("memory:/dedicated-range-{extension}-{start}-{count}");
+ setup_dirs(&io, &path).await;
+ let commit = setup_data_evolution_commit(&io, &path);
+ let files = ["first.parquet", "second.parquet"]
+ .into_iter()
+ .map(|name| {
+ let mut file = test_data_file(name, 5);
+ file.file_source = Some(0);
+ file
+ })
+ .collect();
+ commit
+ .commit(vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ files,
+ )])
+ .await
+ .unwrap();
+ let mut file = test_data_file(&format!("update.{extension}"),
count);
+ file.file_source = Some(0);
+ file.first_row_id = Some(start);
+ file.write_cols = Some(vec!["name".into()]);
+ let mut message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(),
0, vec![file]);
+ message.check_from_snapshot = Some(1);
+ let result = commit.commit(vec![message]).await;
+ if valid {
+ result.unwrap();
+ assert_eq!(latest_snapshot(&io, &path).await.unwrap().id(), 2);
+ } else {
+ let error = result.expect_err("dedicated range must fit in one
normal file");
+ assert!(
+ error.to_string().contains("Row ID existence conflict"),
+ "{error}"
+ );
+ assert_eq!(latest_snapshot(&io, &path).await.unwrap().id(), 1);
+ }
+ }
+ }
+}
+
+#[test]
+fn row_tracking_dedicated_file_cannot_supply_normal_range_existence() {
+ let io = test_file_io();
+ let commit = setup_data_evolution_commit(&io,
"memory:/dedicated-existence");
+ let entry = |name: &str, start: i64, rows: i64| {
+ let mut file = test_data_file(name, rows);
+ file.file_source = Some(0);
+ file.first_row_id = Some(start);
+ file.write_cols = Some(vec!["name".into()]);
+ ManifestEntry::new(FileKind::Add, EMPTY_SERIALIZED_ROW.clone(), 0, -1,
file, 2)
+ };
+ let base = vec![entry("first.parquet", 0, 5), entry("stale.blob", 0, 10)];
+ for name in ["update.blob", "update.parquet"] {
+ let delta = vec![entry(name, 0, 10)];
+ assert!(commit
+ .check_row_id_existence(&base, &delta, Some(10))
+ .is_err());
+ }
+ // The merged-layout check must also catch an older dedicated file that
+ // remains after a change to the normal file boundaries.
+ let merged = vec![
+ entry("first.parquet", 0, 5),
+ entry("second.parquet", 5, 5),
+ entry("stale.blob", 0, 10),
+ ];
+ assert!(commit
+ .check_row_id_range_conflicts(&CommitKind::APPEND, Some(1), &merged)
+ .is_err());
+ assert!(commit
+ .check_row_id_range_conflicts(&CommitKind::APPEND, None, &merged)
+ .is_ok());
+}
+
+#[tokio::test]
+async fn row_tracking_ignore_index_update_preserves_index_metadata() {
+ let io = test_file_io();
+ let path = "memory:/ignore-index-update";
+ setup_dirs(&io, path).await;
+ let table = test_data_evolution_table(&io,
path).copy_with_options(HashMap::from([(
+ "global-index.column-update-action".into(),
+ "IGNORE".into(),
+ )]));
+ let commit = TableCommit::new(table, "test-user".into());
+ let mut file = test_data_file("initial.parquet", 10);
+ file.file_source = Some(0);
+ let mut seed = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![file]);
+ let index = test_global_index_file("global-name.index", 1, 0, 9);
+ seed.new_index_files.push(index.clone());
+ commit.commit(vec![seed]).await.unwrap();
+ let mut partial = test_data_file("updated.parquet", 10);
+ partial.file_source = Some(0);
+ partial.first_row_id = Some(0);
+ partial.write_cols = Some(vec!["name".into()]);
+ let mut update = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![partial]);
+ update.check_from_snapshot = Some(1);
+ commit.commit(vec![update]).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.next_row_id(), Some(10));
+ let index_entries = IndexManifest::read(
+ &io,
+ &format!("{path}/manifest/{}", snapshot.index_manifest().unwrap()),
+ )
+ .await
+ .unwrap();
+ assert_eq!(index_entries.len(), 1);
+ assert_eq!(index_entries[0].index_file, index);
+ assert_eq!(index_entries[0].kind, FileKind::Add);
+}
+
+async fn row_tracking_partial_delete_setup(io: &FileIO, path: &str) ->
(TableCommit, DataFileMeta) {
+ setup_dirs(io, path).await;
+ let commit = setup_data_evolution_commit(io, path);
+ let mut initial = test_data_file("initial.parquet", 10);
+ initial.file_source = Some(0);
+ commit
+ .commit(vec![CommitMessage::new(
+ EMPTY_SERIALIZED_ROW.clone(),
+ 0,
+ vec![initial],
+ )])
+ .await
+ .unwrap();
+ let mut partial = test_data_file("partial-name.parquet", 10);
+ partial.file_source = Some(0);
+ partial.first_row_id = Some(0);
+ partial.write_cols = Some(vec!["name".into()]);
+ let mut message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![partial]);
+ message.check_from_snapshot = Some(1);
+ commit.commit(vec![message]).await.unwrap();
+ let snapshot = latest_snapshot(io, path).await.unwrap();
+ let entries = active_entries(io, path, &snapshot).await;
+ let partial = entries
+ .iter()
+ .find(|entry| entry.file().file_name == "partial-name.parquet")
+ .unwrap()
+ .file()
+ .clone();
+ (commit, partial)
+}
+
+#[tokio::test]
+async fn row_tracking_historical_partial_delete_checks_updated_columns() {
+ for column in ["name", "id"] {
+ let io = test_file_io();
+ let path = format!("memory:/historical-partial-delete-{column}");
+ let (commit, partial) = row_tracking_partial_delete_setup(&io,
&path).await;
+ let mut deletion = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![]);
+ deletion.deleted_files.push(partial);
+ deletion.check_from_snapshot = Some(2);
+ commit.commit(vec![deletion]).await.unwrap();
+ let snapshot = latest_snapshot(&io, &path).await.unwrap();
+ assert_eq!(snapshot.id(), 3);
+ assert_eq!(snapshot.commit_kind(), &CommitKind::OVERWRITE);
+ let changes = commit.read_delta_entries(None,
&snapshot).await.unwrap();
+ assert_eq!(changes.len(), 1);
+ assert_eq!(changes[0].kind(), &FileKind::Delete);
+
+ let mut stale = test_data_file("stale-update.parquet", 10);
+ stale.file_source = Some(0);
+ stale.first_row_id = Some(0);
+ stale.write_cols = Some(vec![column.into()]);
+ let mut message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![stale]);
+ message.check_from_snapshot = Some(2);
+ let result = commit.commit(vec![message]).await;
+ let expected_snapshot = if column == "name" {
+ let error = result.expect_err("a historical DELETE must conflict
on the same column");
+ assert!(
+ error
+ .to_string()
+ .contains("multiple MERGE INTO operations have encountered
conflicts"),
+ "{error}"
+ );
+ 3
+ } else {
+ result.unwrap();
+ 4
+ };
+ let latest = latest_snapshot(&io, &path).await.unwrap();
+ assert_eq!(latest.id(), expected_snapshot);
+ assert_eq!(latest.next_row_id(), Some(10));
+ }
+}
+
+#[tokio::test]
+async fn row_tracking_partial_delete_checks_concurrent_updated_columns() {
+ for column in ["name", "id"] {
+ let io = test_file_io();
+ let path = format!("memory:/current-partial-delete-{column}");
+ let (commit, partial) = row_tracking_partial_delete_setup(&io,
&path).await;
+ let mut concurrent = test_data_file("concurrent-update.parquet", 10);
+ concurrent.file_source = Some(0);
+ concurrent.first_row_id = Some(0);
+ concurrent.write_cols = Some(vec![column.into()]);
+ let mut message = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![concurrent]);
+ message.check_from_snapshot = Some(2);
+ commit.commit(vec![message]).await.unwrap();
+
+ let mut deletion = CommitMessage::new(EMPTY_SERIALIZED_ROW.clone(), 0,
vec![]);
+ deletion.deleted_files.push(partial);
+ deletion.check_from_snapshot = Some(2);
+ let result = commit.commit(vec![deletion]).await;
+ let expected_snapshot = if column == "name" {
+ let error = result.expect_err("a current DELETE must conflict on
the same column");
+ assert!(
+ error
+ .to_string()
+ .contains("multiple MERGE INTO operations have encountered
conflicts"),
+ "{error}"
+ );
+ 3
+ } else {
+ result.unwrap();
+ 4
+ };
+ let latest = latest_snapshot(&io, &path).await.unwrap();
+ assert_eq!(latest.id(), expected_snapshot);
+ assert_eq!(latest.next_row_id(), Some(10));
+ let partial_still_exists = active_entries(&io, &path, &latest)
+ .await
+ .iter()
+ .any(|entry| entry.file().file_name == "partial-name.parquet");
+ assert_eq!(partial_still_exists, column == "name");
+ }
+}