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 5aeb1144 fix(commit): close remaining TableCommit parity gaps (#915)
5aeb1144 is described below
commit 5aeb11444a34b18ae6ea3d6d6b5362769aaaa998
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 19:31:03 2026 +0800
fix(commit): close remaining TableCommit parity gaps (#915)
---
bindings/python/tests/test_table_commit.py | 3 +-
.../datafusion/tests/partition_count_pushdown.rs | 25 +-
crates/paimon/src/table/data_evolution_writer.rs | 29 +-
crates/paimon/src/table/table_commit.rs | 334 ++++++++---
.../paimon/src/table/table_commit/parity_tests.rs | 13 +-
.../src/table/table_commit/recovery_tests.rs | 615 +++++++++++++++++++++
crates/paimon/src/table/table_write.rs | 72 +++
7 files changed, 983 insertions(+), 108 deletions(-)
diff --git a/bindings/python/tests/test_table_commit.py
b/bindings/python/tests/test_table_commit.py
index b168b224..12b6f666 100644
--- a/bindings/python/tests/test_table_commit.py
+++ b/bindings/python/tests/test_table_commit.py
@@ -404,7 +404,8 @@ def
test_close_cleans_unprepared_output_but_preserves_prepared_files(tmp_path, b
assert prepared_paths
_write(writer, [2], [20])
_write(writer, [3], [30])
- assert set(tmp_path.rglob("*.parquet")) - prepared_paths
+ # Rolled files may still be closing in the background. close() must await
+ # that work and clean outstanding output before we inspect the directory.
writer.close()
assert set(tmp_path.rglob("*.parquet")) == prepared_paths
builder.new_commit().commit(1, messages)
diff --git a/crates/integrations/datafusion/tests/partition_count_pushdown.rs
b/crates/integrations/datafusion/tests/partition_count_pushdown.rs
index 357d7282..f20674f2 100644
--- a/crates/integrations/datafusion/tests/partition_count_pushdown.rs
+++ b/crates/integrations/datafusion/tests/partition_count_pushdown.rs
@@ -706,10 +706,11 @@ async fn
test_removed_file_deletion_vector_does_not_reduce_count() {
.clone();
assert_eq!(removed.row_count, 2);
- // The public commit API can remove a data file while retaining its DV
index.
+ // Remove the data file and its DV together, as required by commit
validation.
let mut message =
paimon::table::CommitMessage::new(entry.partition.clone(),
entry.bucket, vec![]);
message.deleted_files.push(removed);
+ message.deleted_index_files.push(entry.index_file.clone());
table
.new_write_builder()
.new_commit()
@@ -717,7 +718,27 @@ async fn
test_removed_file_deletion_vector_does_not_reduce_count() {
.await
.unwrap();
let after = snapshots.get_latest_snapshot().await.unwrap().unwrap();
- assert_eq!(after.index_manifest(), snapshot.index_manifest());
+ // Model a legacy snapshot that retained the removed file's DV. Build this
+ // reader-compatibility fixture directly; new commits must reject that
state.
+ let mut metadata = serde_json::to_value(&after).unwrap();
+ metadata["indexManifest"] = serde_json::json!(snapshot.index_manifest());
+ let snapshot_path = snapshots.snapshot_path(after.id());
+ table.file_io().delete_file(&snapshot_path).await.unwrap();
+ table
+ .file_io()
+ .new_output(&snapshot_path)
+ .unwrap()
+ .write(serde_json::to_vec(&metadata).unwrap().into())
+ .await
+ .unwrap();
+ assert_eq!(
+ snapshots
+ .get_snapshot(after.id())
+ .await
+ .unwrap()
+ .index_manifest(),
+ snapshot.index_manifest()
+ );
let plan = table.new_read_builder().new_scan().plan().await.unwrap();
assert!(plan
.splits()
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index d2855348..9373dcae 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -30,7 +30,7 @@ use crate::deletion_vector::{DeletionVector,
DeletionVectorFactory};
use crate::io::FileIO;
use crate::spec::{
bucket_path, BinaryRow, CoreOptions, DataField, DataFileMeta, DataType,
DeletionVectorMeta,
- FileKind, IndexFileMeta, IndexManifest, PartitionComputer,
EMPTY_BINARY_ROW,
+ FileKind, IndexFileMeta, IndexManifest, PartitionComputer, Snapshot,
EMPTY_BINARY_ROW,
};
use crate::table::commit_message::CommitMessage;
use crate::table::data_file_writer::DataFileWriter;
@@ -38,7 +38,6 @@ use crate::table::index_file_path::IndexFileLocation;
use crate::table::source::data_evolution_anchor_file;
use crate::table::stats_filter::group_by_overlapping_row_id;
use crate::table::DataSplitBuilder;
-use crate::table::SnapshotManager;
use crate::table::Table;
use crate::Result;
use arrow_array::{Array, ArrayRef, Int64Array, RecordBatch};
@@ -485,8 +484,14 @@ impl DataEvolutionDeleteWriter {
return Ok(Vec::new());
}
- let scan = self
- .table
+ let snapshot = super::time_travel::resolve_snapshot(&self.table)
+ .await?
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "No files with row tracking found in target
table".into(),
+ source: None,
+ })?;
+ let scan_table = self.table.copy_with_pinned_snapshot(&snapshot);
+ let scan = scan_table
.new_read_builder()
.new_scan()
.with_scan_all_files();
@@ -572,7 +577,7 @@ impl DataEvolutionDeleteWriter {
let mut messages = Vec::new();
for ((partition, bucket), delete_plan) in deletes_by_bucket {
if let Some(message) = self
- .prepare_bucket_delete_message(partition, bucket, delete_plan)
+ .prepare_bucket_delete_message(partition, bucket, delete_plan,
&snapshot)
.await?
{
messages.push(message);
@@ -587,13 +592,10 @@ impl DataEvolutionDeleteWriter {
partition: Vec<u8>,
bucket: i32,
delete_plan: BucketDeletePlan,
+ snapshot: &Snapshot,
) -> Result<Option<CommitMessage>> {
let (mut bitmaps, deleted_index_files) = self
- .read_existing_bucket_deletion_vectors(
- &partition,
- bucket,
- delete_plan.check_from_snapshot,
- )
+ .read_existing_bucket_deletion_vectors(&partition, bucket,
snapshot)
.await?;
let mut changed = false;
@@ -661,13 +663,8 @@ impl DataEvolutionDeleteWriter {
&self,
partition: &[u8],
bucket: i32,
- snapshot_id: i64,
+ snapshot: &Snapshot,
) -> Result<(IndexMap<String, RoaringBitmap>, Vec<IndexFileMeta>)> {
- let snapshot_manager = SnapshotManager::new(
- self.table.file_io().clone(),
- self.table.location().to_string(),
- );
- let snapshot = snapshot_manager.get_snapshot(snapshot_id).await?;
let Some(index_manifest_name) = snapshot.index_manifest() else {
return Ok((IndexMap::new(), Vec::new()));
};
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index b0b8ec2c..b1d399b2 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -52,6 +52,14 @@ type PartitionBucketKey = (Vec<u8>, i32);
type RowIdRange = (i64, i64);
type ExistingRowIdRanges = HashMap<PartitionBucketKey, Vec<RowIdRange>>;
+fn commit_file_identifier(entry: &ManifestEntry) -> crate::spec::Identifier {
+ let mut identifier = entry.identifier();
+ if is_empty_partition(&identifier.partition) {
+ identifier.partition.clear();
+ }
+ identifier
+}
+
fn validate_file_entries<'a>(entries: impl IntoIterator<Item = &'a
ManifestEntry>) -> Result<()> {
// Mirror Java FileEntry.mergeEntries while also rejecting repeated entries
// of the same kind instead of letting two DELETEs cancel each other.
@@ -65,7 +73,7 @@ fn validate_file_entries<'a>(entries: impl IntoIterator<Item
= &'a ManifestEntry
let mut files = HashMap::new();
for entry in entries {
let state = files
- .entry(entry.identifier())
+ .entry(commit_file_identifier(entry))
.or_insert_with(State::default);
let duplicate = match entry.kind() {
FileKind::Add => {
@@ -252,6 +260,17 @@ impl TableCommit {
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()?;
self.table.ensure_not_branch_reference_for_write()?;
commits.sort_by_key(|(id, _)| *id);
+ for pair in commits.windows(2) {
+ if pair[0].0 == pair[1].0 {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Duplicate commit identifier {} in recovery batch",
+ pair[0].0
+ ),
+ source: None,
+ });
+ }
+ }
let latest = self.snapshot_manager.get_latest_snapshot().await?;
let mut pending = Vec::new();
for (id, messages) in commits {
@@ -266,48 +285,19 @@ impl TableCommit {
}
let count = pending.len();
for (id, messages) in pending {
- self.commit_with_identifier(messages, id).await?;
+ self.filter_and_commit_with_identifier(messages, id).await?;
}
Ok(count)
}
async fn check_recovery_files(&self, messages: &[CommitMessage]) ->
Result<()> {
- let index_in_bucket =
-
CoreOptions::new(self.table.schema().options()).index_file_in_data_file_dir();
- for message in messages {
- let bucket_path = self.bucket_path(&message.partition,
message.bucket)?;
- let mut paths = Vec::new();
- for file in message
- .new_files
- .iter()
- .chain(&message.new_changelog_files)
- .chain(&message.compact_after)
- .chain(&message.compact_changelog_files)
- {
- paths.extend(file.collect_files(&bucket_path));
- }
- for file in message
- .new_index_files
- .iter()
- .chain(&message.compact_new_index_files)
- {
- paths.push(committed_index_file_path(
- self.table.location().trim_end_matches('/'),
- &bucket_path,
- index_in_bucket,
- file,
- ));
- }
- for path in paths {
- if !self.table.file_io().exists(&path).await? {
- return Err(crate::Error::DataInvalid {
- message: format!("Cannot recover commit: file '{path}'
does not exist"),
- source: None,
- });
- }
- }
- }
- Ok(())
+ reject_compact_increment(messages)?;
+ self.check_recovery_entries(
+ &self.messages_to_entries(messages),
+ &self.messages_to_changelog_entries(messages),
+ &self.messages_to_index_entries(messages),
+ )
+ .await
}
/// Commit new files in APPEND mode.
@@ -493,7 +483,10 @@ impl TableCommit {
validate_fixed_bucket_commit_mode(&commit_messages, true)?;
validate_bucket_ownership(&commit_messages)?;
- if commit_messages.is_empty() && static_partitions.is_none() {
+ if commit_messages.is_empty()
+ && static_partitions.is_none()
+ && !self.table.schema().partition_fields().is_empty()
+ {
return Ok(());
}
@@ -958,6 +951,7 @@ impl TableCommit {
let commit_empty_overwrite = plan.commit_kind_hint() ==
CommitKind::OVERWRITE;
let commit_empty_append =
!self.ignore_empty_commit && matches!(plan,
CommitEntriesPlan::Direct { .. });
+ let check_append_files = filter_committed;
let mut filter_committed = filter_committed;
let mut publication_uncertain = false;
@@ -972,6 +966,7 @@ impl TableCommit {
{
break;
}
+ self.check_recovered_files_exist(&plan).await?;
filter_committed = false;
}
if let Some(start_snapshot_id) =
duplicate_check_start_snapshot_id {
@@ -989,7 +984,12 @@ impl TableCommit {
}
validate_expected_latest_snapshot(expected_snapshot_id,
&latest_snapshot)?;
let resolved = self
- .resolve_commit(&mut plan, &latest_snapshot,
retry_state.as_deref())
+ .resolve_commit(
+ &mut plan,
+ &latest_snapshot,
+ retry_state.as_deref(),
+ check_append_files,
+ )
.await?;
if resolved.entries.is_empty()
@@ -1048,6 +1048,68 @@ impl TableCommit {
}
}
+ /// Validate files before replaying a commit whose identity is no longer
visible.
+ async fn check_recovered_files_exist(&self, plan: &CommitEntriesPlan) ->
Result<()> {
+ let (entries, changelog_entries, index_entries) = match plan {
+ CommitEntriesPlan::Direct {
+ entries,
+ changelog_entries,
+ new_index_entries,
+ ..
+ } => (
+ entries.as_slice(),
+ changelog_entries.as_slice(),
+ new_index_entries,
+ ),
+ CommitEntriesPlan::Overwrite {
+ new_entries,
+ new_index_entries,
+ ..
+ } => (new_entries.as_slice(), &[][..], new_index_entries),
+ };
+ self.check_recovery_entries(entries, changelog_entries, index_entries)
+ .await
+ }
+
+ async fn check_recovery_entries(
+ &self,
+ entries: &[ManifestEntry],
+ changelog_entries: &[ManifestEntry],
+ index_entries: &[IndexManifestEntry],
+ ) -> Result<()> {
+ let mut paths = HashSet::new();
+ for entry in entries
+ .iter()
+ .chain(changelog_entries)
+ .filter(|e| *e.kind() == FileKind::Add)
+ {
+ paths.extend(
+ entry
+ .file()
+ .collect_files(&self.bucket_path(entry.partition(),
entry.bucket())?),
+ );
+ }
+ let index_in_data_dir =
+
CoreOptions::new(self.table.schema().options()).index_file_in_data_file_dir();
+ for entry in index_entries.iter().filter(|e| e.kind == FileKind::Add) {
+ paths.insert(committed_index_file_path(
+ self.table.location().trim_end_matches('/'),
+ &self.bucket_path(&entry.partition, entry.bucket)?,
+ index_in_data_dir,
+ &entry.index_file,
+ ));
+ }
+ for path in paths {
+ if !self.table.file_io().exists(&path).await? {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot recover commit: file '{path}'
does not exist"),
+ source: None,
+ });
+ }
+ }
+ Ok(())
+ }
+
/// Single commit attempt.
async fn try_commit_once(
&self,
@@ -1565,6 +1627,7 @@ impl TableCommit {
plan: &mut CommitEntriesPlan,
latest_snapshot: &Option<Snapshot>,
retry_state: Option<&RetryState>,
+ check_append_files: bool,
) -> Result<ResolvedCommit> {
let file_io = self.snapshot_manager.file_io();
let manifest_dir = self.snapshot_manager.manifest_dir();
@@ -1578,7 +1641,6 @@ impl TableCommit {
} => {
validate_file_entries(entries.iter())?;
- let has_delete = entries.iter().any(|e| *e.kind() ==
FileKind::Delete);
let kind = direct_commit_kind(entries, new_index_entries);
let has_partition_bucket_counts = entries
.iter()
@@ -1587,16 +1649,12 @@ impl TableCommit {
&& entries.iter().any(|entry| {
*entry.kind() == FileKind::Add && entry.bucket() ==
POSTPONE_BUCKET
});
- let detect_conflicts = has_delete
+ let detect_conflicts = check_append_files
+ || kind == CommitKind::OVERWRITE
|| check_from_snapshot.is_some()
|| has_partition_bucket_counts
|| has_postpone_entries;
let base_data_files = if detect_conflicts {
- self.check_deletion_vector_conflicts(
- latest_snapshot.as_ref(),
- new_index_entries,
- *check_from_snapshot,
- )?;
self.detect_commit_conflicts(
latest_snapshot,
retry_state,
@@ -1624,6 +1682,13 @@ impl TableCommit {
new_index_entries,
)?);
let all = Self::merge_index_entries(&previous, &index_entries,
false)?;
+ self.check_deletion_vector_references(
+ latest_snapshot,
+ entries,
+ &index_entries,
+ &all,
+ )
+ .await?;
let index_manifest_changed = all != previous;
let index_manifest_name = latest_snapshot
.as_ref()
@@ -1685,6 +1750,13 @@ impl TableCommit {
}
}
let all = Self::merge_index_entries(&all, &new_index_entries,
false)?;
+ self.check_deletion_vector_references(
+ latest_snapshot,
+ &entries,
+ &new_index_entries,
+ &all,
+ )
+ .await?;
let index_manifest_changed = all != previous;
let index_manifest_name = latest_snapshot
.as_ref()
@@ -1806,6 +1878,15 @@ impl TableCommit {
let Some(write_cols) = entry.file().write_cols.as_ref() else {
continue;
};
+ // 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)
+ {
+ continue;
+ }
for col in write_cols {
if !is_system_field(col) {
updated_cols.insert(col.clone());
@@ -2395,40 +2476,103 @@ impl TableCommit {
Ok(())
}
- fn check_deletion_vector_conflicts(
+ /// Validate DV references against the state this attempt will publish.
Index
+ /// merging separately verifies replacement identities and one DV per file.
+ async fn check_deletion_vector_references(
&self,
- latest_snapshot: Option<&Snapshot>,
+ latest_snapshot: &Option<Snapshot>,
+ data_entries: &[ManifestEntry],
index_entries: &[IndexManifestEntry],
- check_from_snapshot: Option<i64>,
+ merged_indexes: &[IndexManifestEntry],
) -> Result<()> {
- if !self.data_evolution_enabled {
- return Ok(());
- }
- let Some(check_from_snapshot) = check_from_snapshot else {
- return Ok(());
+ let file_key = |partition: &[u8], bucket: i32, name: &str| {
+ (
+ if is_empty_partition(partition) {
+ vec![]
+ } else {
+ partition.to_vec()
+ },
+ bucket,
+ name.to_string(),
+ )
};
- let has_deletion_vector_index_change = index_entries
+ let deleted_files = data_entries
.iter()
- .any(|entry| entry.index_file.index_type ==
DELETION_VECTORS_INDEX_TYPE);
- if !has_deletion_vector_index_change {
- return Ok(());
+ .filter(|entry| *entry.kind() == FileKind::Delete)
+ .map(|entry| file_key(entry.partition(), entry.bucket(),
&entry.file().file_name))
+ .collect::<HashSet<_>>();
+ let added_dvs = index_entries
+ .iter()
+ .filter(|entry| {
+ entry.kind == FileKind::Add
+ && entry.index_file.index_type ==
DELETION_VECTORS_INDEX_TYPE
+ })
+ .map(|entry| file_key(&entry.partition, entry.bucket,
&entry.index_file.file_name))
+ .collect::<HashSet<_>>();
+ let mut referenced_files = HashSet::new();
+ for entry in merged_indexes
+ .iter()
+ .filter(|entry| entry.index_file.index_type ==
DELETION_VECTORS_INDEX_TYPE)
+ {
+ let is_added = added_dvs.contains(&file_key(
+ &entry.partition,
+ entry.bucket,
+ &entry.index_file.file_name,
+ ));
+ if let Some(ranges) = &entry.index_file.deletion_vectors_ranges {
+ for name in ranges.keys() {
+ let key = file_key(&entry.partition, entry.bucket, name);
+ // Also forbid retaining a DV after deleting its data file.
+ if is_added || deleted_files.contains(&key) {
+ referenced_files.insert(key);
+ }
+ }
+ }
}
- let Some(latest_snapshot) = latest_snapshot else {
+ if referenced_files.is_empty() {
return Ok(());
+ }
+ // DV partitions need not be the partitions of the data entries in a
+ // mixed commit. Do not reuse the data conflict scan's narrower cache.
+ let fields = self.table.schema().partition_fields();
+ let filter = if fields.is_empty() {
+ None
+ } else {
+ Some(PartitionFilter::from_partition_set(
+ referenced_files
+ .iter()
+ .map(|(partition, _, _)| partition.clone())
+ .collect(),
+ &fields,
+ )?)
};
- if latest_snapshot.id() <= check_from_snapshot {
- return Ok(());
+ let base = self
+ .scan_snapshot_entries(latest_snapshot, filter.as_ref())
+ .await?;
+ let mut active_files = base
+ .iter()
+ .map(|entry| file_key(entry.partition(), entry.bucket(),
&entry.file().file_name))
+ .collect::<HashSet<_>>();
+ for entry in data_entries {
+ let key = file_key(entry.partition(), entry.bucket(),
&entry.file().file_name);
+ match entry.kind() {
+ FileKind::Add => {
+ active_files.insert(key);
+ }
+ FileKind::Delete => {
+ active_files.remove(&key);
+ }
+ }
}
-
- Err(crate::Error::DataInvalid {
- message: format!(
- "Row ID conflict: deletion-vector DELETE was prepared from
snapshot \
- {check_from_snapshot}, but latest snapshot is {}. Retry with
the latest \
- deletion vectors.",
- latest_snapshot.id()
- ),
- source: None,
- })
+ for key in referenced_files {
+ if !active_files.contains(&key) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Deletion vector references missing data
file '{}' in bucket {}; prepare the DELETE again from the latest snapshot",
key.2, key.1),
+ source: None,
+ });
+ }
+ }
+ Ok(())
}
fn check_delete_entries_against_base(
@@ -2438,10 +2582,10 @@ impl TableCommit {
) -> Result<()> {
let mut active_identifiers = base_entries
.iter()
- .map(ManifestEntry::identifier)
+ .map(commit_file_identifier)
.collect::<HashSet<_>>();
for entry in delta_entries {
- let identifier = entry.identifier();
+ let identifier = commit_file_identifier(entry);
match entry.kind() {
FileKind::Add => {
active_identifiers.insert(identifier);
@@ -2602,10 +2746,17 @@ impl TableCommit {
return Ok(());
};
- let source_snapshot = self
- .snapshot_manager
- .get_snapshot(check_from_snapshot)
- .await?;
+ let write_ranges =
self.build_row_id_write_ranges(delta_entries).await?;
+ if write_ranges.is_empty() {
+ return Ok(());
+ }
+ let source_snapshot = if check_from_snapshot == latest_snapshot.id() {
+ latest_snapshot.clone()
+ } else {
+ self.snapshot_manager
+ .get_snapshot(check_from_snapshot)
+ .await?
+ };
let check_next_row_id =
source_snapshot
.next_row_id()
@@ -2616,15 +2767,14 @@ impl TableCommit {
source: None,
})?;
- let write_ranges =
self.build_row_id_write_ranges(delta_entries).await?;
- if write_ranges.is_empty() {
- return Ok(());
- }
-
let delta_entry_refs = delta_entries.iter().collect::<Vec<_>>();
let partition_filter =
self.build_entries_partition_filter(&delta_entry_refs)?;
for snapshot_id in check_from_snapshot + 1..=latest_snapshot.id() {
- let snapshot =
self.snapshot_manager.get_snapshot(snapshot_id).await?;
+ let snapshot = if snapshot_id == latest_snapshot.id() {
+ latest_snapshot.clone()
+ } else {
+ self.snapshot_manager.get_snapshot(snapshot_id).await?
+ };
if snapshot.commit_kind() == &CommitKind::COMPACT {
continue;
}
@@ -3478,6 +3628,7 @@ mod tests {
mod parity {
use super::*;
include!("table_commit/parity_tests.rs");
+ include!("table_commit/recovery_tests.rs");
}
#[tokio::test]
@@ -4163,6 +4314,12 @@ mod tests {
let overwrite =
CommitMessage::new(vec![], 0,
vec![test_data_file("overwrite.parquet", 100)]);
+ file_io
+ .new_output(&format!("{table_path}/bucket-0/overwrite.parquet"))
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared data"))
+ .await
+ .unwrap();
commit
.overwrite_with_identifier(vec![overwrite.clone()], None, 2)
.await
@@ -4497,8 +4654,8 @@ mod tests {
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
- err_msg.contains("Row ID conflict"),
- "expected row-id conflict for stale DV commit, got: {err_msg}"
+ err_msg.contains("Conflicting deletion vectors"),
+ "expected conflicting vectors for stale DV commit, got: {err_msg}"
);
let snapshot = latest_snapshot(&file_io, table_path).await.unwrap();
@@ -4771,6 +4928,7 @@ mod tests {
commit.commit(vec![first]).await.unwrap();
let mut data_file = test_data_file("data-update-id.parquet", 10);
+ data_file.first_row_id = Some(0);
data_file.write_cols = Some(vec!["id".to_string()]);
let result = commit
.commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
@@ -4807,6 +4965,7 @@ mod tests {
commit.commit(vec![first]).await.unwrap();
let mut data_file = test_data_file("data-update-id.parquet", 10);
+ data_file.first_row_id = Some(0);
data_file.write_cols = Some(vec!["id".to_string()]);
commit
.commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
@@ -4837,6 +4996,7 @@ mod tests {
commit.commit(vec![first]).await.unwrap();
let mut data_file = test_data_file("data-update-name.parquet", 10);
+ data_file.first_row_id = Some(0);
data_file.write_cols = Some(vec!["name".to_string()]);
let result = commit
.commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
@@ -4879,6 +5039,7 @@ mod tests {
commit.commit(vec![first]).await.unwrap();
let mut data_file = test_data_file("data-update-name.parquet", 10);
+ data_file.first_row_id = Some(0);
data_file.write_cols = Some(vec!["name".to_string()]);
commit
.commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
@@ -4902,6 +5063,7 @@ mod tests {
commit.commit(vec![first]).await.unwrap();
let mut data_file = test_data_file("data-update-name.parquet", 10);
+ data_file.first_row_id = Some(0);
data_file.write_cols = Some(vec!["name".to_string()]);
commit
.commit(vec![CommitMessage::new(vec![], 0, vec![data_file])])
diff --git a/crates/paimon/src/table/table_commit/parity_tests.rs
b/crates/paimon/src/table/table_commit/parity_tests.rs
index 972530c6..8799eddd 100644
--- a/crates/paimon/src/table/table_commit/parity_tests.rs
+++ b/crates/paimon/src/table/table_commit/parity_tests.rs
@@ -123,7 +123,7 @@ async fn dv_replacement_rejects_missing_old_index() {
setup_dirs(&io, path).await;
let commit = setup_commit(&io, path);
commit
- .commit(vec![dv_message("current-dv", "data.parquet")])
+ .commit(vec![append_message("data.parquet"), dv_message("current-dv",
"data.parquet")])
.await
.unwrap();
let mut message = dv_message("new-dv", "data.parquet");
@@ -142,6 +142,8 @@ async fn
dv_replacement_is_overwrite_and_preserves_unrelated_vectors() {
let commit = setup_commit(&io, path);
commit
.commit(vec![
+ append_message("data-a"),
+ append_message("data-b"),
dv_message("old", "data-a"),
dv_message("other", "data-b"),
])
@@ -181,12 +183,14 @@ async fn
unrelated_dv_delete_does_not_bypass_indexed_column_policy() {
);
let commit = TableCommit::new(table, "test-user".into());
let mut initial = dv_message("old-dv", "data");
+ initial.new_files.push(test_data_file("data", 10));
initial
.new_index_files
.push(test_global_index_file("global", 0, 0, 9));
commit.commit(vec![initial]).await.unwrap();
let mut update = append_message("update.parquet");
update.new_files[0].write_cols = Some(vec!["id".into()]);
+ update.new_files[0].first_row_id = Some(0);
let mut delete = dv_message("new-dv", "data");
delete
.deleted_index_files
@@ -356,6 +360,9 @@ async fn mixed_append_cannot_bypass_stale_dv_check() {
for name in ["data", "concurrent"] {
let mut message = append_message(name);
message.new_files[0].file_source = Some(0);
+ if name == "concurrent" {
+ message.new_index_files =
vec![test_deletion_vector_index_file("first-dv", "data")];
+ }
commit.commit(vec![message]).await.unwrap();
}
let mut delete = dv_message("dv", "data");
@@ -364,7 +371,7 @@ async fn mixed_append_cannot_bypass_stale_dv_check() {
append.new_files[0].file_source = Some(0);
let error = commit.commit(vec![append, delete]).await.unwrap_err();
assert!(
- error.to_string().contains("deletion-vector DELETE"),
+ error.to_string().contains("Conflicting deletion vectors"),
"{error}"
);
assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 2);
@@ -545,7 +552,7 @@ async fn
dv_publication_response_loss_uses_overwrite_identity() {
});
commit.snapshot_commit = publisher.clone();
commit
- .commit_with_identifier(vec![dv_message("dv", "data")], 7)
+ .commit_with_identifier(vec![append_message("data"), dv_message("dv",
"data")], 7)
.await
.unwrap();
assert_eq!(
diff --git a/crates/paimon/src/table/table_commit/recovery_tests.rs
b/crates/paimon/src/table/table_commit/recovery_tests.rs
new file mode 100644
index 00000000..631a5ce9
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/recovery_tests.rs
@@ -0,0 +1,615 @@
+// 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.
+
+#[tokio::test]
+async fn empty_unpartitioned_overwrite_clears_old_rows() {
+ let io = test_file_io();
+ let path = "memory:/audit-empty-overwrite";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ commit
+ .commit(vec![append_message("old.parquet")])
+ .await
+ .unwrap();
+ commit.overwrite(Vec::new(), None).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(
+ active_entries(&io, path, &snapshot).await.len(),
+ 0,
+ "Empty unpartitioned overwrite must remove old data; snapshot={}
count={:?}",
+ snapshot.id(),
+ snapshot.total_record_count()
+ );
+}
+
+#[tokio::test]
+async fn recovery_rejects_duplicate_file_after_identity_expiry() {
+ let io = test_file_io();
+ let path = "memory:/audit-expired-identity";
+ setup_dirs(&io, path).await;
+ let a = TableCommit::new(test_table(&io, path), "writer-a".into());
+ let b = TableCommit::new(test_table(&io, path), "writer-b".into());
+ let first = append_message("a.parquet");
+ for name in ["a.parquet", "b.parquet"] {
+ io.new_output(&format!("{path}/bucket-0/{name}"))
+ .unwrap()
+ .write(bytes::Bytes::from_static(
+ b"physical file retained by latest snapshot",
+ ))
+ .await
+ .unwrap();
+ }
+ a.commit_with_identifier(vec![first.clone()], 7)
+ .await
+ .unwrap();
+ b.commit_with_identifier(vec![append_message("b.parquet")], 1)
+ .await
+ .unwrap();
+ // Expiration removes the old snapshot, but both data files remain
referenced by snapshot 2.
+ let manager = a.table.snapshot_manager();
+ manager.delete_snapshot(1).await.unwrap();
+ manager.write_earliest_hint(2).await.unwrap();
+ assert!(io
+ .exists(&format!("{path}/bucket-0/a.parquet"))
+ .await
+ .unwrap());
+ let result = a
+ .filter_and_commit_with_identifier(vec![first.clone()], 7)
+ .await;
+ let snapshot = manager.get_latest_snapshot().await.unwrap().unwrap();
+ let active_rows: i64 = active_entries(&io, path, &snapshot)
+ .await
+ .iter()
+ .map(|e| e.file().row_count)
+ .sum();
+ assert!(result.is_err(), "Duplicate replay must be rejected;
result={result:?}, latest={}, snapshot count={:?}, actual active
rows={active_rows}", snapshot.id(), snapshot.total_record_count());
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.total_record_count(), Some(active_rows));
+ let result = a.filter_and_commit(vec![(7, vec![first])]).await;
+ assert!(
+ result.is_err(),
+ "Batch recovery must reject duplicate replay: {result:?}"
+ );
+ assert_eq!(
+ manager.get_latest_snapshot().await.unwrap().unwrap().id(),
+ 2
+ );
+}
+
+#[tokio::test]
+async fn recovery_rejects_missing_file_after_identity_expiry() {
+ let io = test_file_io();
+ let path = "memory:/audit-expired-missing-file";
+ setup_dirs(&io, path).await;
+ let a = TableCommit::new(test_table(&io, path), "writer-a".into());
+ let b = TableCommit::new(test_table(&io, path), "writer-b".into());
+ let first = append_message("a.parquet");
+ let data_path = format!("{path}/bucket-0/a.parquet");
+ io.new_output(&data_path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"data"))
+ .await
+ .unwrap();
+ a.commit_with_identifier(vec![first.clone()], 7)
+ .await
+ .unwrap();
+ b.truncate_table().await.unwrap();
+ let manager = a.table.snapshot_manager();
+ manager.delete_snapshot(1).await.unwrap();
+ manager.write_earliest_hint(2).await.unwrap();
+ io.delete_file(&data_path).await.unwrap();
+ let result = a
+ .filter_and_commit_with_identifier(vec![first.clone()], 7)
+ .await;
+ let snapshot = manager.get_latest_snapshot().await.unwrap().unwrap();
+ assert!(result.is_err(), "Replay referencing cleaned file must fail;
result={result:?}, latest={}, count={:?}, physical_exists={}", snapshot.id(),
snapshot.total_record_count(), io.exists(&data_path).await.unwrap());
+ let error = a
+ .filter_and_commit(vec![(7, vec![first])])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains(&data_path), "{error}");
+ assert_eq!(
+ manager.get_latest_snapshot().await.unwrap().unwrap().id(),
+ 2
+ );
+}
+
+#[tokio::test]
+async fn recovery_checks_data_changelog_extra_and_index_paths() {
+ let io = test_file_io();
+ let path = "memory:/recovery-paths";
+ setup_dirs(&io, path).await;
+ let table = test_table_with_options(
+ &io,
+ path,
+ HashMap::from([("index-file-in-data-file-dir".into(), "true".into())]),
+ );
+ let commit = TableCommit::new(table, "recover".into());
+ let mut message = append_message("data");
+ message.new_files[0].external_path = Some(format!("{path}/external/data"));
+ message.new_files[0].extra_files = vec!["data.index".into()];
+ let mut changelog = test_data_file("changelog", 10);
+ changelog.extra_files = vec!["changelog.index".into()];
+ message.new_changelog_files.push(changelog);
+ let mut bucket_index = test_global_index_file("hash", 0, 0, 9);
+ bucket_index.index_type = "HASH".into();
+ bucket_index.global_index_meta = None;
+ let mut external_index = test_global_index_file("external-index", 1, 0, 9);
+ external_index.external_path = Some(format!("{path}/external/index"));
+ message.new_index_files = vec![
+ bucket_index,
+ test_global_index_file("global", 0, 0, 9),
+ external_index,
+ ];
+ let paths = [
+ "external/data",
+ "external/data.index",
+ "bucket-0/changelog",
+ "bucket-0/changelog.index",
+ "bucket-0/hash",
+ "index/global",
+ "external/index",
+ ]
+ .map(|name| format!("{path}/{name}"));
+ for file in &paths {
+ io.new_output(file)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared"))
+ .await
+ .unwrap();
+ }
+ for missing in &paths {
+ io.delete_file(missing).await.unwrap();
+ let error = commit
+ .filter_and_commit_with_identifier(vec![message.clone()], 1)
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains(missing), "{error}");
+ let error = commit
+ .filter_and_commit(vec![(1, vec![message.clone()])])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains(missing), "{error}");
+ assert!(latest_snapshot(&io, path).await.is_none());
+ io.new_output(missing)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared"))
+ .await
+ .unwrap();
+ }
+ commit
+ .filter_and_commit_with_identifier(vec![message.clone()], 1)
+ .await
+ .unwrap();
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 1);
+ // A known committed identity is filtered before files are checked.
+ io.delete_file(&paths[0]).await.unwrap();
+ commit
+ .filter_and_commit_with_identifier(vec![message], 1)
+ .await
+ .unwrap();
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 1);
+}
+
+#[tokio::test]
+async fn batch_recovery_validates_all_groups_before_publication() {
+ let io = test_file_io();
+ let path = "memory:/batch-recovery";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ let first = append_message("first.parquet");
+ let second = append_message("second.parquet");
+ let first_path = format!("{path}/bucket-0/first.parquet");
+ let second_path = format!("{path}/bucket-0/second.parquet");
+ io.new_output(&first_path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared"))
+ .await
+ .unwrap();
+ let error = commit
+ .filter_and_commit(vec![(7, vec![first.clone()]), (7,
vec![second.clone()])])
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("Duplicate commit identifier 7"),
+ "{error}"
+ );
+ assert!(latest_snapshot(&io, path).await.is_none());
+
+ let pending = vec![(8, vec![second]), (7, vec![first])];
+ let error = commit.filter_and_commit(pending.clone()).await.unwrap_err();
+ assert!(error.to_string().contains(&second_path), "{error}");
+ assert!(latest_snapshot(&io, path).await.is_none());
+ io.new_output(&second_path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared"))
+ .await
+ .unwrap();
+ assert_eq!(commit.filter_and_commit(pending.clone()).await.unwrap(), 2);
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert_eq!(snapshot.commit_identifier(), 8);
+ assert_eq!(snapshot.total_record_count(), Some(20));
+ io.delete_file(&first_path).await.unwrap();
+ io.delete_file(&second_path).await.unwrap();
+ assert_eq!(commit.filter_and_commit(pending).await.unwrap(), 0);
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 2);
+}
+
+#[tokio::test]
+async fn dv_commits_allow_unrelated_writes_but_reject_replaced_vectors() {
+ let io = test_file_io();
+ let path = "memory:/dv-concurrency";
+ setup_dirs(&io, path).await;
+ let table = Table::new(
+ io.clone(),
+ Identifier::new("default", "dv"),
+ path.into(),
+ test_partitioned_schema().copy_with_options(HashMap::from([
+ ("data-evolution.enabled".into(), "true".into()),
+ ("row-tracking.enabled".into(), "true".into()),
+ ])),
+ None,
+ );
+ let commit = TableCommit::new(table, "dv".into());
+ let part_a = partition_bytes("a");
+ let part_b = partition_bytes("b");
+ let append = |partition, name| {
+ let mut file = test_data_file(name, 10);
+ file.file_source = Some(0);
+ CommitMessage::new(partition, 0, vec![file])
+ };
+ commit
+ .commit(vec![
+ append(part_a.clone(), "data-a"),
+ append(part_b.clone(), "data-b"),
+ ])
+ .await
+ .unwrap();
+ let mut delete_a = dv_message("dv-a", "data-a");
+ delete_a.partition = part_a.clone();
+ delete_a.check_from_snapshot = Some(1);
+ let mut delete_b = dv_message("dv-b", "data-b");
+ delete_b.partition = part_b.clone();
+ delete_b.check_from_snapshot = Some(1);
+ commit.commit(vec![delete_a]).await.unwrap();
+ // Mixed APPEND in A and DELETE in B must scan both relevant partitions.
+ commit
+ .commit(vec![append(part_a.clone(), "append-a"), delete_b])
+ .await
+ .unwrap();
+ let mut replace = dv_message("replacement", "data-a");
+ replace.partition = part_a.clone();
+ replace.deleted_index_files = vec![test_deletion_vector_index_file("dv-a",
"data-a")];
+ replace.check_from_snapshot = Some(2);
+ commit.commit(vec![replace.clone()]).await.unwrap();
+ replace.new_index_files[0].file_name = "stale-replacement".into();
+ let error = commit.commit(vec![replace]).await.unwrap_err();
+ assert!(
+ error.to_string().contains("missing deletion vector"),
+ "{error}"
+ );
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 4);
+ let snapshot = latest_snapshot(&io, path).await;
+ let indexes = TableCommit::read_prev_index_entries(&io,
&format!("{path}/manifest"), &snapshot)
+ .await
+ .unwrap();
+ assert_eq!(
+ indexes
+ .iter()
+ .map(|entry| entry.index_file.file_name.as_str())
+ .collect::<HashSet<_>>(),
+ HashSet::from(["replacement", "dv-b"])
+ );
+}
+
+#[tokio::test]
+async fn dv_references_must_survive_concurrent_and_same_commit_data_deletion()
{
+ for concurrent in [true, false] {
+ let io = test_file_io();
+ let path = format!("memory:/dv-removed-data-{concurrent}");
+ setup_dirs(&io, &path).await;
+ let commit = setup_commit(&io, &path);
+ let data = test_data_file("data", 10);
+ commit
+ .commit(vec![CommitMessage::new(vec![], 0, vec![data.clone()])])
+ .await
+ .unwrap();
+ let mut dv = dv_message("dv", "data");
+ dv.check_from_snapshot = Some(1);
+ if concurrent {
+ commit.truncate_table().await.unwrap();
+ } else {
+ dv.deleted_files = vec![data];
+ }
+ let error = commit.commit(vec![dv]).await.unwrap_err();
+ assert!(
+ error.to_string().contains("references missing data file"),
+ "{error}"
+ );
+ assert_eq!(
+ latest_snapshot(&io, &path).await.unwrap().id(),
+ if concurrent { 2 } else { 1 }
+ );
+ }
+ let io = test_file_io();
+ let path = "memory:/dv-retained-on-deleted-file";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ let data = test_data_file("data", 10);
+ commit
+ .commit(vec![
+ CommitMessage::new(vec![], 0, vec![data.clone()]),
+ dv_message("dv", "data"),
+ ])
+ .await
+ .unwrap();
+ let mut deletion = CommitMessage::new(vec![], 0, vec![]);
+ deletion.deleted_files = vec![data];
+ assert!(commit
+ .commit(vec![deletion.clone()])
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("references missing data file"));
+ deletion.deleted_index_files = vec![test_deletion_vector_index_file("dv",
"data")];
+ commit.commit(vec![deletion]).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert!(snapshot.index_manifest().is_none());
+ assert!(active_entries(&io, path, &snapshot).await.is_empty());
+}
+
+#[tokio::test]
+async fn explicit_row_id_update_still_checks_indexed_columns() {
+ let io = test_file_io();
+ let path = "memory:/explicit-row-id-update";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ let mut index = CommitMessage::new(vec![], 0, vec![]);
+ index.new_index_files = vec![test_global_index_file("global", 0, 0, 9)];
+ commit.commit(vec![index]).await.unwrap();
+ let mut update = append_message("update");
+ update.new_files[0].write_cols = Some(vec!["id".into(),
crate::spec::ROW_ID_FIELD_NAME.into()]);
+ let error = commit.commit(vec![update]).await.unwrap_err();
+ assert!(
+ error.to_string().contains("globally indexed columns"),
+ "{error}"
+ );
+}
+
+struct ConcurrentDvCommit {
+ table: Table,
+ change: &'static str,
+ calls: std::sync::atomic::AtomicUsize,
+}
+
+#[async_trait::async_trait]
+impl SnapshotCommit for ConcurrentDvCommit {
+ async fn commit(&self, snapshot: &Snapshot, _: &[PartitionStatistics]) ->
Result<bool> {
+ if self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 {
+ let other = TableCommit::new(self.table.clone(),
"concurrent".into());
+ match self.change {
+ "append" =>
other.commit(vec![append_message("unrelated")]).await?,
+ "delete" => other.truncate_table().await?,
+ "dv" => {
+ other
+ .commit(vec![dv_message("competing-dv", "data")])
+ .await?
+ }
+ _ => unreachable!(),
+ }
+ return Ok(false);
+ }
+ self.table
+ .snapshot_manager()
+ .commit_snapshot(snapshot)
+ .await
+ }
+}
+
+#[tokio::test]
+async fn dv_retry_revalidates_data_files_and_vectors() {
+ for change in ["append", "delete", "dv"] {
+ let io = test_file_io();
+ let path = format!("memory:/dv-retry-{change}");
+ setup_dirs(&io, &path).await;
+ let mut commit = setup_commit(&io, &path);
+ commit.commit(vec![append_message("data")]).await.unwrap();
+ commit.commit_min_retry_wait_ms = 0;
+ commit.commit_max_retry_wait_ms = 0;
+ let publisher = Arc::new(ConcurrentDvCommit {
+ table: commit.table.clone(),
+ change,
+ calls: std::sync::atomic::AtomicUsize::new(0),
+ });
+ commit.snapshot_commit = publisher.clone();
+ let mut deletion = dv_message("dv", "data");
+ deletion.check_from_snapshot = Some(1);
+ let result = commit.commit(vec![deletion]).await;
+ let snapshot = latest_snapshot(&io, &path).await.unwrap();
+ if change == "append" {
+ result.unwrap();
+ assert_eq!(snapshot.id(), 3);
+ assert_eq!(snapshot.total_record_count(), Some(20));
+
assert_eq!(publisher.calls.load(std::sync::atomic::Ordering::SeqCst), 2);
+ } else {
+ let error = result.unwrap_err().to_string();
+ assert!(
+ error.contains(if change == "delete" {
+ "references missing data file"
+ } else {
+ "Conflicting deletion vectors"
+ }),
+ "{error}"
+ );
+ assert_eq!(snapshot.id(), 2);
+
assert_eq!(publisher.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+ }
+ }
+}
+
+#[tokio::test]
+async fn rest_delete_writer_pins_catalog_snapshot_and_preserves_vectors() {
+ use crate::api::rest_api::RESTApi;
+ use crate::common::Options;
+ use arrow_array::{Array, Int32Array, RecordBatch, StringArray};
+ use axum::{
+ body::Bytes,
+ http::{Method, Uri},
+ Json, Router,
+ };
+ use futures::TryStreamExt;
+ use std::sync::Mutex;
+ let io = test_file_io();
+ let path = "memory:/audit-rest-dv";
+ setup_dirs(&io, path).await;
+ let schema =
test_data_evolution_schema().copy_with_options(HashMap::from([(
+ "deletion-vectors.enabled".into(),
+ "true".into(),
+ )]));
+ let seed_table = Table::new(
+ io.clone(),
+ Identifier::new("database", "table"),
+ path.into(),
+ schema.clone(),
+ None,
+ );
+ let mut writer = crate::table::TableWrite::new(&seed_table,
"seed".into()).unwrap();
+ let batch = RecordBatch::try_from_iter(vec![
+ (
+ "id",
+ Arc::new(Int32Array::from(vec![0, 1, 2])) as arrow_array::ArrayRef,
+ ),
+ (
+ "name",
+ Arc::new(StringArray::from(vec!["zero", "one", "two"])) as
arrow_array::ArrayRef,
+ ),
+ ])
+ .unwrap();
+ writer.write_arrow_batch(&batch).await.unwrap();
+ TableCommit::new(seed_table, "seed".into())
+ .commit(writer.prepare_commit().await.unwrap())
+ .await
+ .unwrap();
+ let mut value = serde_json::to_value(latest_snapshot(&io,
path).await.unwrap()).unwrap();
+ value["id"] = 7.into();
+ let snapshot = Arc::new(Mutex::new(
+ serde_json::from_value::<Snapshot>(value).unwrap(),
+ ));
+ let posts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let loads = Arc::new(std::sync::atomic::AtomicUsize::new(0));
+ let handler_loads = loads.clone();
+ let handler_snapshot = snapshot.clone();
+ let handler_posts = posts.clone();
+ let app = Router::new().fallback(move |method: Method, uri: Uri, body:
Bytes| {
+ let loads = handler_loads.clone();
+ let snapshot = handler_snapshot.clone();
+ let posts = handler_posts.clone();
+ async move {
+ let response = if method == Method::POST &&
uri.path().ends_with("/commit") {
+ posts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ let request: serde_json::Value =
serde_json::from_slice(&body).unwrap();
+ let next: Snapshot =
serde_json::from_value(request["snapshot"].clone()).unwrap();
+ assert_eq!(next.id(), snapshot.lock().unwrap().id() + 1);
+ *snapshot.lock().unwrap() = next;
+ // Force the retry/deduplication path after catalog
publication.
+ serde_json::json!({"success": false})
+ } else if uri.path().ends_with("/snapshot") {
+ loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ serde_json::json!({"snapshot": {"snapshot":
*snapshot.lock().unwrap(), "recordCount": 10}})
+ } else {
+ serde_json::json!({"schemaId": 0})
+ };
+ Json(response)
+ }
+ });
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let mut options = Options::new();
+ options.set("uri", format!("http://{}", listener.local_addr().unwrap()));
+ options.set("prefix", "test");
+ options.set("token.provider", "bear");
+ options.set("token", "test-token");
+ let server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
+ let api = Arc::new(RESTApi::new(options.clone(), false).await.unwrap());
+ let identifier = Identifier::new("database", "table");
+ let env =
+ crate::table::RESTEnv::new(identifier.clone(), "uuid".into(), api,
options, false, None);
+ let table = Table::new(io.clone(), identifier, path.into(), schema,
Some(env));
+ let mut commit = TableCommit::new(table.clone(), "rest-writer".into());
+ commit.commit_min_retry_wait_ms = 0;
+ commit.commit_max_retry_wait_ms = 0;
+ async fn remaining_ids(table: &Table) -> Vec<i32> {
+ let builder = table.new_read_builder();
+ let plan = builder.new_scan().plan().await.unwrap();
+ let read = builder.new_read().unwrap();
+ let batches = read
+ .to_arrow(plan.splits())
+ .unwrap()
+ .try_collect::<Vec<_>>()
+ .await
+ .unwrap();
+ let mut ids = Vec::new();
+ for batch in batches {
+ let column = batch
+ .column(0)
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .unwrap();
+ ids.extend((0..column.len()).map(|i| column.value(i)));
+ }
+ ids.sort();
+ ids
+ }
+ for row_id in [0, 1] {
+ let mut deletion = table.new_write_builder().new_delete().unwrap();
+ deletion.add_row_ids([row_id]).unwrap();
+ let before = loads.load(std::sync::atomic::Ordering::SeqCst);
+ let messages = deletion.prepare_commit().await.unwrap();
+ assert_eq!(loads.load(std::sync::atomic::Ordering::SeqCst) - before,
1);
+ assert_eq!(messages[0].check_from_snapshot, Some(7 + row_id));
+ commit
+ .commit_with_identifier(messages, 42 + row_id)
+ .await
+ .unwrap();
+ assert_eq!(
+ remaining_ids(&table).await,
+ if row_id == 0 { vec![1, 2] } else { vec![2] }
+ );
+ }
+ let mut left = table.new_write_builder().new_delete().unwrap();
+ left.add_row_ids([2]).unwrap();
+ let mut right = table.new_write_builder().new_delete().unwrap();
+ right.add_row_ids([2]).unwrap();
+ let left = left.prepare_commit().await.unwrap();
+ let right = right.prepare_commit().await.unwrap();
+ commit.commit_with_identifier(left, 44).await.unwrap();
+ let error = commit.commit_with_identifier(right, 45).await.unwrap_err();
+ assert!(
+ error.to_string().contains("missing deletion vector"),
+ "{error}"
+ );
+ assert!(remaining_ids(&table).await.is_empty());
+ assert_eq!(snapshot.lock().unwrap().id(), 10);
+ assert_eq!(posts.load(std::sync::atomic::Ordering::SeqCst), 3);
+ for id in 7..=10 {
+ assert!(!io
+ .exists(&format!("{path}/snapshot/snapshot-{id}"))
+ .await
+ .unwrap());
+ }
+ server.abort();
+}
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index f23c83f3..fa819f13 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -4675,4 +4675,76 @@ pub(in crate::table) mod tests {
"append tables keep file-level bin pack"
);
}
+ #[tokio::test]
+ async fn dedicated_insert_preserves_existing_global_index() {
+ for action in ["THROW_ERROR", "DROP_PARTITION_INDEX"] {
+ let file_io = test_file_io();
+ let path = "memory:/audit-dedicated-index-append";
+ setup_dirs(&file_io, path).await;
+ let table = Table::new(
+ file_io.clone(),
+ Identifier::new("default", "audit"),
+ path.into(),
+ test_vector_table_schema("parquet").copy_with_options(
+ std::collections::HashMap::from([(
+ "global-index.column-update-action".into(),
+ action.into(),
+ )]),
+ ),
+ None,
+ );
+ async fn append(table: &Table, id: i32) -> crate::Result<()> {
+ let mut writer = TableWrite::new(table, "audit".into())?;
+ writer
+ .write_arrow_batch(&make_vector_batch(vec![id],
vec![vec![1.0, 0.0]]))
+ .await?;
+ let messages = writer.prepare_commit().await?;
+ assert!(messages[0]
+ .new_files
+ .iter()
+ .all(|f| f.first_row_id.is_none() &&
f.write_cols.is_some()));
+ TableCommit::new(table.clone(), "audit".into())
+ .commit(messages)
+ .await
+ }
+ append(&table, 1).await.unwrap();
+ let mut message = crate::table::CommitMessage::new(vec![], 0,
vec![]);
+ message.new_index_files.push(crate::spec::IndexFileMeta {
+ index_type: "lumina".into(),
+ file_name: "audit-index".into(),
+ file_size: 1,
+ row_count: 1,
+ deletion_vectors_ranges: None,
+ external_path: None,
+ global_index_meta: Some(crate::spec::GlobalIndexMeta {
+ index_field_id: 1,
+ row_range_start: 0,
+ row_range_end: 0,
+ extra_field_ids: None,
+ index_meta: None,
+ source_meta: None,
+ }),
+ });
+ TableCommit::new(table.clone(), "audit-index".into())
+ .commit(vec![message])
+ .await
+ .unwrap();
+ append(&table, 2).await.unwrap();
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(snapshot.next_row_id(), Some(2));
+ let entries = crate::spec::IndexManifest::read(
+ &file_io,
+ &format!("{path}/manifest/{}",
snapshot.index_manifest().unwrap()),
+ )
+ .await
+ .unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].index_file.file_name, "audit-index");
+ }
+ }
}