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 ef540b1e fix(commit): align TableCommit validation and retries with
Java (#913)
ef540b1e is described below
commit ef540b1e94d439551a3e1f27d46483743f1a7ce9
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Sep 22 15:59:04 2026 +0800
fix(commit): align TableCommit validation and retries with Java (#913)
---
crates/paimon/src/spec/core_options.rs | 14 -
crates/paimon/src/table/table_commit.rs | 418 ++++++++++----
.../paimon/src/table/table_commit/parity_tests.rs | 642 +++++++++++++++++++++
3 files changed, 933 insertions(+), 141 deletions(-)
diff --git a/crates/paimon/src/spec/core_options.rs
b/crates/paimon/src/spec/core_options.rs
index bd20c09b..41a9664c 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -87,7 +87,6 @@ pub(crate) const PATH_OPTION: &str = "path";
const MANIFEST_COMPRESSION_OPTION: &str = "manifest.compression";
const MANIFEST_TARGET_FILE_SIZE_OPTION: &str = "manifest.target-file-size";
const MANIFEST_TARGET_SIZE_OPTION: &str = "manifest.target-size";
-const MANIFEST_MERGE_MIN_COUNT_OPTION: &str = "manifest.merge-min-count";
const MANIFEST_SIDECAR_ENABLED_OPTION: &str = "manifest.sidecar.enabled";
const MANIFEST_SORT_ENABLED_OPTION: &str = "manifest-sort.enabled";
const WRITE_PARQUET_BUFFER_SIZE_OPTION: &str = "write.parquet-buffer-size";
@@ -138,7 +137,6 @@ const DEFAULT_SOURCE_SPLIT_TARGET_SIZE: i64 = 128 * 1024 *
1024;
const DEFAULT_SOURCE_SPLIT_OPEN_FILE_COST: i64 = 4 * 1024 * 1024;
const DEFAULT_MANIFEST_COMPRESSION: &str = "zstd";
const DEFAULT_MANIFEST_TARGET_FILE_SIZE: i64 = 8 * 1024 * 1024;
-const DEFAULT_MANIFEST_MERGE_MIN_COUNT: usize = 30;
const DEFAULT_PARTITION_DEFAULT_NAME: &str = "__DEFAULT_PARTITION__";
const DEFAULT_CHANGELOG_FILE_PREFIX: &str = "changelog-";
const DEFAULT_TARGET_FILE_SIZE: i64 = 256 * 1024 * 1024;
@@ -1233,15 +1231,6 @@ impl<'a> CoreOptions<'a> {
.unwrap_or(DEFAULT_MANIFEST_TARGET_FILE_SIZE)
}
- /// Compatibility option; Rust commits do not currently compact manifests.
- pub fn manifest_merge_min_count(&self) -> usize {
- self.options
- .get(MANIFEST_MERGE_MIN_COUNT_OPTION)
- .and_then(|v| v.parse().ok())
- .filter(|v| *v > 0)
- .unwrap_or(DEFAULT_MANIFEST_MERGE_MIN_COUNT)
- }
-
/// Whether manifest block sidecars are read and written.
///
/// An explicit `manifest.sidecar.enabled` value wins. When it is absent,
@@ -2729,7 +2718,6 @@ mod tests {
assert!(!core.row_tracking_enabled());
assert_eq!(core.manifest_compression(), "zstd");
assert_eq!(core.manifest_target_size(), 8 * 1024 * 1024);
- assert_eq!(core.manifest_merge_min_count(), 30);
assert!(!core.manifest_sidecar_enabled());
}
@@ -2747,7 +2735,6 @@ mod tests {
"1kb".to_string(),
),
(MANIFEST_COMPRESSION_OPTION.to_string(), "null".to_string()),
- (MANIFEST_MERGE_MIN_COUNT_OPTION.to_string(), "3".to_string()),
]);
let core = CoreOptions::new(&options);
assert_eq!(core.bucket(), 4);
@@ -2758,7 +2745,6 @@ mod tests {
assert!(core.row_tracking_enabled());
assert_eq!(core.manifest_compression(), "null");
assert_eq!(core.manifest_target_size(), 1024);
- assert_eq!(core.manifest_merge_min_count(), 3);
}
#[test]
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index d25a6547..8ce382f0 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -180,7 +180,7 @@ pub struct TableCommit {
impl TableCommit {
pub fn new(table: Table, commit_user: String) -> Self {
- let snapshot_manager = SnapshotManager::new(table.file_io.clone(),
table.location.clone());
+ let snapshot_manager = table.snapshot_manager();
let snapshot_commit = if let Some(env) = &table.rest_env {
env.snapshot_commit()
} else {
@@ -860,75 +860,91 @@ impl TableCommit {
filter_committed && plan.commit_kind_hint() ==
CommitKind::OVERWRITE;
let mut filter_committed = filter_committed;
- loop {
- let latest_snapshot =
self.snapshot_manager.get_latest_snapshot().await?;
- if filter_committed {
- if self
- .is_committed_identifier(&latest_snapshot,
commit_identifier)
- .await?
- {
- break;
+ let mut publication_uncertain = false;
+ let mut last_publication_error = None;
+ let result = async {
+ loop {
+ let latest_snapshot =
self.snapshot_manager.get_latest_snapshot().await?;
+ if filter_committed {
+ if self
+ .is_committed_identifier(&latest_snapshot,
commit_identifier)
+ .await?
+ {
+ break;
+ }
+ filter_committed = false;
}
- filter_committed = false;
- }
- if let Some(start_snapshot_id) = duplicate_check_start_snapshot_id
{
- if self
- .is_duplicate_commit(
- start_snapshot_id,
- &latest_snapshot,
- commit_identifier,
- &plan.commit_kind_hint(),
- )
- .await?
+ if let Some(start_snapshot_id) =
duplicate_check_start_snapshot_id {
+ if self
+ .is_duplicate_commit(
+ start_snapshot_id,
+ &latest_snapshot,
+ commit_identifier,
+ &plan.commit_kind_hint(),
+ )
+ .await?
+ {
+ break;
+ }
+ }
+ validate_expected_latest_snapshot(expected_snapshot_id,
&latest_snapshot)?;
+ let resolved = self
+ .resolve_commit(&mut plan, &latest_snapshot,
retry_state.as_deref())
+ .await?;
+
+ if resolved.entries.is_empty()
+ && resolved.changelog_entries.is_empty()
+ && !resolved.index_manifest_changed
+ && !commit_empty_overwrite
{
break;
}
- }
- validate_expected_latest_snapshot(expected_snapshot_id,
&latest_snapshot)?;
- let resolved = self
- .resolve_commit(&mut plan, &latest_snapshot,
retry_state.as_deref())
- .await?;
- if resolved.entries.is_empty()
- && resolved.changelog_entries.is_empty()
- && !resolved.index_manifest_changed
- && !commit_empty_overwrite
- {
- break;
- }
+ let result = self
+ .try_commit_once(resolved, &latest_snapshot,
commit_identifier)
+ .await?;
- let result = self
- .try_commit_once(resolved, &latest_snapshot, commit_identifier)
- .await?;
+ match result {
+ CommitAttemptResult::Success => break,
+ CommitAttemptResult::Retry(mut state) => {
+ if let Some(error) = state.publication_error.take() {
+ publication_uncertain = true;
+ last_publication_error = Some(error);
+ }
+
duplicate_check_start_snapshot_id.get_or_insert_with(|| {
+ latest_snapshot.as_ref().map(|s| s.id() +
1).unwrap_or(1)
+ });
+ retry_state = Some(state);
+ }
+ }
- match result {
- CommitAttemptResult::Success => break,
- CommitAttemptResult::Retry(state) => {
- duplicate_check_start_snapshot_id.get_or_insert_with(|| {
- latest_snapshot.as_ref().map(|s| s.id() +
1).unwrap_or(1)
+ let elapsed_ms = current_time_millis() - start_time_ms;
+ if elapsed_ms > self.commit_timeout_ms || retry_count >=
self.commit_max_retries {
+ let snap_id =
duplicate_check_start_snapshot_id.unwrap_or(1);
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Commit failed for snapshot {} after {} millis
with {} retries, \
+ there may exist commit conflicts between multiple
jobs.",
+ snap_id, elapsed_ms, retry_count
+ ),
+ source: last_publication_error.map(|error|
Box::new(error) as _),
});
- retry_state = Some(state);
}
- }
- let elapsed_ms = current_time_millis() - start_time_ms;
- if elapsed_ms > self.commit_timeout_ms || retry_count >=
self.commit_max_retries {
- let snap_id = duplicate_check_start_snapshot_id.unwrap_or(1);
- return Err(crate::Error::DataInvalid {
- message: format!(
- "Commit failed for snapshot {} after {} millis with {}
retries, \
- there may exist commit conflicts between multiple
jobs.",
- snap_id, elapsed_ms, retry_count
- ),
- source: None,
- });
+ self.commit_retry_wait(retry_count).await;
+ retry_count += 1;
}
- self.commit_retry_wait(retry_count).await;
- retry_count += 1;
+ Ok(())
+ }
+ .await;
+ match result {
+ Err(error) if publication_uncertain =>
Err(crate::Error::UnexpectedError {
+ message: "Commit outcome may be unknown; retain prepared files
and retry with the same commit identifier".into(),
+ source: Some(Box::new(error)),
+ }),
+ result => result,
}
-
- Ok(())
}
/// Single commit attempt.
@@ -938,6 +954,45 @@ impl TableCommit {
latest_snapshot: &Option<Snapshot>,
commit_identifier: i64,
) -> Result<CommitAttemptResult> {
+ let mut created_files = Vec::new();
+ let prepared = self
+ .prepare_snapshot(
+ &mut resolved,
+ latest_snapshot,
+ commit_identifier,
+ &mut created_files,
+ )
+ .await;
+ let (snapshot, statistics) = match prepared {
+ Ok(prepared) => prepared,
+ Err(error) => {
+ for path in created_files {
+ let _ =
self.snapshot_manager.file_io().delete_file(&path).await;
+ }
+ return Err(error);
+ }
+ };
+ // Once publication starts its outcome may be unknown. These files must
+ // remain available even if the response is lost or a later retry
fails.
+ let publication_error = match self.snapshot_commit.commit(&snapshot,
&statistics).await {
+ Ok(true) => return Ok(CommitAttemptResult::Success),
+ Ok(false) => None,
+ Err(error) => Some(error),
+ };
+ Ok(CommitAttemptResult::Retry(Box::new(RetryState {
+ latest_snapshot: latest_snapshot.clone(),
+ base_data_files: resolved.base_data_files.take(),
+ publication_error,
+ })))
+ }
+
+ async fn prepare_snapshot(
+ &self,
+ resolved: &mut ResolvedCommit,
+ latest_snapshot: &Option<Snapshot>,
+ commit_identifier: i64,
+ created_files: &mut Vec<String>,
+ ) -> Result<(Snapshot, Vec<PartitionStatistics>)> {
let new_snapshot_id = latest_snapshot.as_ref().map(|s| s.id() +
1).unwrap_or(1);
// Row tracking
@@ -956,7 +1011,7 @@ impl TableCommit {
let (assigned, nrid) = self.assign_row_tracking_meta(
new_snapshot_id,
first_row_id_start,
- resolved.entries,
+ std::mem::take(&mut resolved.entries),
)?;
resolved.entries = assigned;
next_row_id = Some(nrid);
@@ -984,10 +1039,12 @@ impl TableCommit {
&manifest_dir,
&new_manifest_prefix,
&resolved.entries,
+ created_files,
)
.await?;
// Write delta manifest list
+ created_files.push(delta_manifest_list_path.clone());
ManifestList::write_with_compression(
file_io,
&delta_manifest_list_path,
@@ -1006,8 +1063,10 @@ impl TableCommit {
&manifest_dir,
&changelog_manifest_prefix,
&resolved.changelog_entries,
+ created_files,
)
.await?;
+ created_files.push(changelog_manifest_list_path.clone());
ManifestList::write_with_compression(
file_io,
&changelog_manifest_list_path,
@@ -1045,6 +1104,7 @@ impl TableCommit {
vec![]
};
+ created_files.push(base_manifest_list_path.clone());
ManifestList::write_with_compression(
file_io,
&base_manifest_list_path,
@@ -1063,35 +1123,89 @@ impl TableCommit {
}
total_record_count += delta_record_count;
+ if let Some(entries) = &resolved.new_index_manifest_entries {
+ resolved.index_manifest_name = self
+ .write_index_manifest(file_io, &manifest_dir, entries,
created_files)
+ .await?;
+ }
+ let schema_id = self.latest_schema_id().await?;
+ let statistics_file = self
+ .inherited_statistics(latest_snapshot.as_ref(), schema_id)
+ .await?;
let snapshot = Snapshot::builder()
.version(3)
.id(new_snapshot_id)
- .schema_id(self.table.schema().id())
+ .schema_id(schema_id)
.base_manifest_list(base_manifest_list_name)
.delta_manifest_list(delta_manifest_list_name)
.commit_user(self.commit_user.clone())
.commit_identifier(commit_identifier)
- .commit_kind(resolved.kind)
+ .commit_kind(resolved.kind.clone())
.time_millis(current_time_millis())
.total_record_count(Some(total_record_count))
.delta_record_count(Some(delta_record_count))
.changelog_manifest_list(changelog_record_count.map(|_|
changelog_manifest_list_name))
.changelog_manifest_list_size(changelog_manifest_list_size)
.changelog_record_count(changelog_record_count)
+ .watermark(latest_snapshot.as_ref().and_then(Snapshot::watermark))
+ .statistics(statistics_file)
.next_row_id(next_row_id)
- .index_manifest(resolved.index_manifest_name)
+ .index_manifest(resolved.index_manifest_name.clone())
.build();
let statistics =
self.generate_partition_statistics(&resolved.entries)?;
- if self.snapshot_commit.commit(&snapshot, &statistics).await? {
- Ok(CommitAttemptResult::Success)
- } else {
- Ok(CommitAttemptResult::Retry(Box::new(RetryState {
- latest_snapshot: latest_snapshot.clone(),
- base_data_files: resolved.base_data_files.take(),
- })))
+ Ok((snapshot, statistics))
+ }
+
+ async fn latest_schema_id(&self) -> Result<i64> {
+ if let Some(env) = &self.table.rest_env {
+ return env
+ .api()
+ .get_table(env.identifier())
+ .await?
+ .schema_id
+ .ok_or_else(|| crate::Error::DataInvalid {
+ message: "REST table response is missing schemaId".into(),
+ source: None,
+ });
}
+ // Tables constructed directly by callers need not have schema files.
+ Ok(self
+ .table
+ .schema_manager()
+ .latest()
+ .await?
+ .map(|schema| schema.id())
+ .unwrap_or(self.table.schema().id()))
+ }
+
+ async fn inherited_statistics(
+ &self,
+ snapshot: Option<&Snapshot>,
+ schema_id: i64,
+ ) -> Result<Option<String>> {
+ let Some(name) = snapshot.and_then(Snapshot::statistics) else {
+ return Ok(None);
+ };
+ // Read the statistics' own schema ID: the snapshot's schema is not a
+ // substitute when older writers have carried incompatible statistics.
+ #[derive(serde::Deserialize)]
+ struct StatisticsSchema {
+ #[serde(rename = "schemaId")]
+ schema_id: i64,
+ }
+ let path = format!(
+ "{}/statistics/{name}",
+ self.table.location().trim_end_matches('/')
+ );
+ let bytes = self.table.file_io().new_input(&path)?.read().await?;
+ let statistics: StatisticsSchema =
+ serde_json::from_slice(&bytes).map_err(|error|
crate::Error::DataInvalid {
+ message: format!("Invalid statistics metadata: {path}"),
+ source: Some(Box::new(error)),
+ })?;
+ Ok((statistics.schema_id == schema_id).then(|| name.to_string()))
}
/// Write an index manifest file from already-merged entries.
@@ -1102,12 +1216,14 @@ impl TableCommit {
file_io: &FileIO,
manifest_dir: &str,
merged_index_entries: &[IndexManifestEntry],
+ created_files: &mut Vec<String>,
) -> Result<Option<String>> {
if merged_index_entries.is_empty() {
return Ok(None);
}
let name = format!("index-manifest-{}-0", uuid::Uuid::new_v4());
let path = format!("{manifest_dir}/{name}");
+ created_files.push(path.clone());
IndexManifest::write_with_compression(
file_io,
&path,
@@ -1125,6 +1241,7 @@ impl TableCommit {
manifest_dir: &str,
name_prefix: &str,
entries: &[ManifestEntry],
+ created_files: &mut Vec<String>,
) -> Result<Vec<ManifestFileMeta>> {
if entries.is_empty() {
return Ok(vec![]);
@@ -1158,6 +1275,7 @@ impl TableCommit {
&file_name,
&entries[chunk_start..chunk_end],
bytes,
+ created_files,
)
.await?;
result.push(meta);
@@ -1181,6 +1299,7 @@ impl TableCommit {
&file_name,
&entries[chunk_start..],
bytes,
+ created_files,
)
.await?;
result.push(meta);
@@ -1197,6 +1316,7 @@ impl TableCommit {
file_name: &str,
entries: &[ManifestEntry],
bytes: Vec<u8>,
+ created_files: &mut Vec<String>,
) -> Result<ManifestFileMeta> {
let file_size = bytes.len() as i64;
let sidecar = if self.manifest_sidecar_enabled {
@@ -1209,6 +1329,7 @@ impl TableCommit {
} else {
None
};
+ created_files.push(path.to_string());
let output = file_io.new_output(path)?;
output.write(bytes::Bytes::from(bytes)).await?;
let sidecar_name = sidecar
@@ -1216,6 +1337,7 @@ impl TableCommit {
.map(|_| format!("{}{}", file_name,
crate::spec::MANIFEST_SIDECAR_SUFFIX));
if let (Some(sidecar), Some(_)) = (sidecar, sidecar_name.as_ref()) {
let sidecar_path = crate::spec::ManifestSidecar::path(path);
+ created_files.push(sidecar_path.clone());
let write_result = async {
let output = file_io.new_output(&sidecar_path)?;
output.write(bytes::Bytes::from(sidecar)).await
@@ -1289,6 +1411,9 @@ impl TableCommit {
let Some(latest) = latest_snapshot else {
return Ok(false);
};
+ if latest.commit_user() == self.commit_user {
+ return Ok(commit_identifier <= latest.commit_identifier());
+ }
let earliest_snapshot_id = self
.snapshot_manager
.earliest_snapshot_id()
@@ -1316,8 +1441,12 @@ impl TableCommit {
commit_kind: &CommitKind,
) -> Result<bool> {
if let Some(latest) = latest_snapshot {
- for snapshot_id in start_snapshot_id..=latest.id() {
- let snap =
self.snapshot_manager.get_snapshot(snapshot_id).await?;
+ for snapshot_id in (start_snapshot_id..=latest.id()).rev() {
+ let snap = if snapshot_id == latest.id() {
+ latest.clone()
+ } else {
+ self.snapshot_manager.get_snapshot(snapshot_id).await?
+ };
if snap.commit_user() == self.commit_user
&& snap.commit_identifier() == commit_identifier
&& snap.commit_kind() == commit_kind
@@ -1348,14 +1477,8 @@ impl TableCommit {
} => {
validate_file_entries(entries.iter())?;
- // Auto-promote to OVERWRITE when CoW rewrites produce Delete
entries.
- // This ensures the snapshot correctly reflects file
replacements.
let has_delete = entries.iter().any(|e| *e.kind() ==
FileKind::Delete);
- let kind = if has_delete {
- CommitKind::OVERWRITE
- } else {
- CommitKind::APPEND
- };
+ let kind = direct_commit_kind(entries, new_index_entries);
let has_partition_bucket_counts = entries
.iter()
.any(|entry| entry.total_buckets() != self.total_buckets);
@@ -1368,9 +1491,8 @@ impl TableCommit {
|| has_partition_bucket_counts
|| has_postpone_entries;
let base_data_files = if detect_conflicts {
- self.check_deletion_vector_index_only_conflict(
+ self.check_deletion_vector_conflicts(
latest_snapshot.as_ref(),
- entries,
new_index_entries,
*check_from_snapshot,
)?;
@@ -1402,14 +1524,10 @@ impl TableCommit {
)?);
let all = Self::merge_index_entries(&previous, &index_entries,
false)?;
let index_manifest_changed = all != previous;
- let index_manifest_name = if index_manifest_changed {
- self.write_index_manifest(file_io, &manifest_dir, &all)
- .await?
- } else {
- latest_snapshot
- .as_ref()
- .and_then(|s| s.index_manifest().map(|s|
s.to_string()))
- };
+ let index_manifest_name = latest_snapshot
+ .as_ref()
+ .and_then(|s| s.index_manifest().map(str::to_string));
+ let new_index_manifest_entries =
index_manifest_changed.then_some(all);
Ok(ResolvedCommit {
entries: entries.clone(),
@@ -1417,6 +1535,7 @@ impl TableCommit {
kind,
index_manifest_name,
index_manifest_changed,
+ new_index_manifest_entries,
base_data_files,
})
}
@@ -1466,14 +1585,10 @@ impl TableCommit {
}
let all = Self::merge_index_entries(&all, &new_index_entries,
false)?;
let index_manifest_changed = all != previous;
- let index_manifest_name = if index_manifest_changed {
- self.write_index_manifest(file_io, &manifest_dir, &all)
- .await?
- } else {
- latest_snapshot
- .as_ref()
- .and_then(|s| s.index_manifest().map(|s|
s.to_string()))
- };
+ let index_manifest_name = latest_snapshot
+ .as_ref()
+ .and_then(|s| s.index_manifest().map(str::to_string));
+ let new_index_manifest_entries =
index_manifest_changed.then_some(all);
Ok(ResolvedCommit {
entries,
@@ -1481,6 +1596,7 @@ impl TableCommit {
kind: CommitKind::OVERWRITE,
index_manifest_name,
index_manifest_changed,
+ new_index_manifest_entries,
base_data_files,
})
}
@@ -1505,12 +1621,18 @@ impl TableCommit {
.iter()
.filter(|entry| entry.kind == FileKind::Delete)
.collect::<Vec<_>>();
- if !deletions.is_empty() {
- all.retain(|entry| {
- !deletions
- .iter()
- .any(|delete| same_index_file_entry(entry, delete))
- });
+ for deletion in deletions {
+ let position = all
+ .iter()
+ .position(|entry| same_index_file_entry(entry, deletion));
+ if let Some(position) = position {
+ all.remove(position);
+ } else if deletion.index_file.index_type ==
DELETION_VECTORS_INDEX_TYPE {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Cannot replace missing deletion vector
index '{}'; prepare the DELETE again from the latest snapshot",
deletion.index_file.file_name),
+ source: None,
+ });
+ }
}
let additions = new_index_entries
@@ -1532,6 +1654,38 @@ impl TableCommit {
});
Self::validate_global_index_overlap(&all, &additions)?;
Self::validate_added_global_index_overlap(&additions)?;
+ let mut dv_files = HashSet::new();
+ let mut dv_names = HashSet::new();
+ for entry in all
+ .iter()
+ .chain(&additions)
+ .filter(|entry| entry.index_file.index_type ==
DELETION_VECTORS_INDEX_TYPE)
+ {
+ let partition = if is_empty_partition(&entry.partition) {
+ &[][..]
+ } else {
+ entry.partition.as_slice()
+ };
+ if !dv_names.insert((partition, entry.bucket,
entry.index_file.file_name.as_str())) {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Duplicate deletion vector index '{}'",
+ entry.index_file.file_name
+ ),
+ source: None,
+ });
+ }
+ if let Some(ranges) = &entry.index_file.deletion_vectors_ranges {
+ for file in ranges.keys() {
+ if !dv_files.insert((partition, entry.bucket,
file.as_str())) {
+ return Err(crate::Error::DataInvalid {
+ message: format!("Conflicting deletion vectors for
data file '{file}'; prepare the DELETE again from the latest snapshot"),
+ source: None,
+ });
+ }
+ }
+ }
+ }
all.extend(additions);
Ok(all)
}
@@ -1542,13 +1696,6 @@ impl TableCommit {
commit_entries: &[ManifestEntry],
new_index_entries: &[IndexManifestEntry],
) -> Result<Vec<IndexManifestEntry>> {
- if new_index_entries
- .iter()
- .any(|entry| entry.kind == FileKind::Delete)
- {
- return Ok(vec![]);
- }
-
let mut updated_cols = HashSet::new();
let mut written_partitions: Vec<Vec<u8>> = Vec::new();
for entry in commit_entries
@@ -1586,6 +1733,9 @@ impl TableCommit {
let mut conflicted_cols = HashSet::new();
for entry in previous_entries {
if entry.kind != FileKind::Add
+ || new_index_entries.iter().any(|deleted| {
+ deleted.kind == FileKind::Delete &&
same_index_file_entry(entry, deleted)
+ })
|| !written_partitions
.iter()
.any(|partition| same_index_partition(partition,
&entry.partition))
@@ -1961,6 +2111,7 @@ impl TableCommit {
if let Some(RetryState {
latest_snapshot: Some(previous_snapshot),
base_data_files: Some(previous_base),
+ ..
}) = retry_state
{
if let Some(incremental) = self
@@ -2143,14 +2294,13 @@ impl TableCommit {
Ok(())
}
- fn check_deletion_vector_index_only_conflict(
+ fn check_deletion_vector_conflicts(
&self,
latest_snapshot: Option<&Snapshot>,
- data_entries: &[ManifestEntry],
index_entries: &[IndexManifestEntry],
check_from_snapshot: Option<i64>,
) -> Result<()> {
- if !self.data_evolution_enabled || !data_entries.is_empty() {
+ if !self.data_evolution_enabled {
return Ok(());
}
let Some(check_from_snapshot) = check_from_snapshot else {
@@ -2992,21 +3142,33 @@ enum CommitEntriesPlan {
impl CommitEntriesPlan {
fn commit_kind_hint(&self) -> CommitKind {
match self {
- CommitEntriesPlan::Direct { entries, .. } => {
- if entries
- .iter()
- .any(|entry| *entry.kind() == FileKind::Delete)
- {
- CommitKind::OVERWRITE
- } else {
- CommitKind::APPEND
- }
- }
+ CommitEntriesPlan::Direct {
+ entries,
+ new_index_entries,
+ ..
+ } => direct_commit_kind(entries, new_index_entries),
CommitEntriesPlan::Overwrite { .. } => CommitKind::OVERWRITE,
}
}
}
+fn direct_commit_kind(
+ entries: &[ManifestEntry],
+ index_entries: &[IndexManifestEntry],
+) -> CommitKind {
+ if entries
+ .iter()
+ .any(|entry| *entry.kind() == FileKind::Delete)
+ || index_entries
+ .iter()
+ .any(|entry| entry.index_file.index_type ==
DELETION_VECTORS_INDEX_TYPE)
+ {
+ CommitKind::OVERWRITE
+ } else {
+ CommitKind::APPEND
+ }
+}
+
/// Fully resolved commit ready for writing.
struct ResolvedCommit {
entries: Vec<ManifestEntry>,
@@ -3014,6 +3176,7 @@ struct ResolvedCommit {
kind: CommitKind,
index_manifest_name: Option<String>,
index_manifest_changed: bool,
+ new_index_manifest_entries: Option<Vec<IndexManifestEntry>>,
base_data_files: Option<Vec<ManifestEntry>>,
}
@@ -3025,6 +3188,7 @@ enum CommitAttemptResult {
struct RetryState {
latest_snapshot: Option<Snapshot>,
base_data_files: Option<Vec<ManifestEntry>>,
+ publication_error: Option<crate::Error>,
}
struct RowIdWriteRange {
@@ -3180,6 +3344,11 @@ fn rand_f64() -> f64 {
mod tests {
use super::*;
+ mod parity {
+ use super::*;
+ include!("table_commit/parity_tests.rs");
+ }
+
#[tokio::test]
async fn abort_still_cleans_up_for_a_query_auth_table() {
let table = crate::table::query_auth_table();
@@ -6117,12 +6286,7 @@ mod tests {
let table_path = "memory:/test_commit_preserves_delete";
setup_dirs(&file_io, table_path).await;
- let table = test_table_with_options(
- &file_io,
- table_path,
- HashMap::from([("manifest.merge-min-count".to_string(),
"2".to_string())]),
- );
- let mut commit = TableCommit::new(table, "test-user".to_string());
+ let mut commit = setup_commit(&file_io, table_path);
let partition = vec![0, 0, 0, 0];
let old_file = test_data_file("old.parquet", 1);
let mut initial_files = vec![old_file.clone()];
diff --git a/crates/paimon/src/table/table_commit/parity_tests.rs
b/crates/paimon/src/table/table_commit/parity_tests.rs
new file mode 100644
index 00000000..972530c6
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/parity_tests.rs
@@ -0,0 +1,642 @@
+// 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.
+
+fn append_message(name: &str) -> CommitMessage {
+ CommitMessage::new(vec![], 0, vec![test_data_file(name, 10)])
+}
+
+fn dv_message(name: &str, data_file: &str) -> CommitMessage {
+ let mut message = CommitMessage::new(vec![], 0, vec![]);
+ message.new_index_files = vec![test_deletion_vector_index_file(name,
data_file)];
+ message
+}
+
+#[tokio::test]
+async fn small_manifests_remain_unchanged_across_many_commits() {
+ let io = test_file_io();
+ let path = "memory:/no-small-manifest-merge";
+ setup_dirs(&io, path).await;
+ let table = test_table_with_options(
+ &io,
+ path,
+ HashMap::from([("manifest.target-file-size".into(), "8 mb".into())]),
+ );
+ let commit = TableCommit::new(table, "test-user".into());
+ let manifest_dir = format!("{path}/manifest");
+ let mut original_manifests = Vec::new();
+ let mut original_bytes = HashMap::new();
+
+ // Every previous delta must remain an unchanged base manifest across
+ // repeated commits, even though all these files fit within the target
size.
+ for id in 0..35 {
+ commit
+ .commit(vec![append_message(&format!("data-{id}.parquet"))])
+ .await
+ .unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ let base = ManifestList::read(
+ &io,
+ &format!("{manifest_dir}/{}", snapshot.base_manifest_list()),
+ )
+ .await
+ .unwrap();
+ assert_eq!(
+ base, original_manifests,
+ "commit {id} rewrote historical manifests"
+ );
+ let delta = ManifestList::read(
+ &io,
+ &format!("{manifest_dir}/{}", snapshot.delta_manifest_list()),
+ )
+ .await
+ .unwrap();
+ assert_eq!(delta.len(), 1);
+ let manifest_path = format!("{manifest_dir}/{}", delta[0].file_name());
+ let bytes =
io.new_input(&manifest_path).unwrap().read().await.unwrap();
+ assert!(original_bytes.insert(manifest_path, bytes).is_none());
+ original_manifests.extend(delta);
+ }
+
+ assert!(
+ original_manifests
+ .iter()
+ .map(ManifestFileMeta::file_size)
+ .sum::<i64>()
+ < commit.manifest_target_size
+ );
+ let stored_manifests = manifest_paths(&io, path)
+ .await
+ .into_iter()
+ .filter(|path| {
+ let name = path.rsplit('/').next().unwrap();
+ name.starts_with("manifest-") &&
!name.starts_with("manifest-list-")
+ })
+ .collect::<HashSet<_>>();
+ assert_eq!(stored_manifests, original_bytes.keys().cloned().collect());
+ for (path, expected) in original_bytes {
+ assert_eq!(io.new_input(&path).unwrap().read().await.unwrap(),
expected);
+ }
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.total_record_count(), Some(350));
+ assert_eq!(active_entries(&io, path, &snapshot).await.len(), 35);
+}
+
+#[tokio::test]
+async fn duplicate_dv_for_one_data_file_is_rejected() {
+ let io = test_file_io();
+ let path = "memory:/duplicate-dv";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ commit
+ .commit(vec![append_message("data.parquet")])
+ .await
+ .unwrap();
+ let error = commit
+ .commit(vec![
+ dv_message("dv-a", "data.parquet"),
+ dv_message("dv-b", "data.parquet"),
+ ])
+ .await
+ .unwrap_err();
+ assert!(error.to_string().contains("deletion vector"), "{error}");
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 1);
+}
+
+#[tokio::test]
+async fn dv_replacement_rejects_missing_old_index() {
+ let io = test_file_io();
+ let path = "memory:/stale-dv-replacement";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ commit
+ .commit(vec![dv_message("current-dv", "data.parquet")])
+ .await
+ .unwrap();
+ let mut message = dv_message("new-dv", "data.parquet");
+ message
+ .deleted_index_files
+ .push(test_deletion_vector_index_file("stale-dv", "data.parquet"));
+ assert!(commit.commit(vec![message]).await.is_err());
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 1);
+}
+
+#[tokio::test]
+async fn dv_replacement_is_overwrite_and_preserves_unrelated_vectors() {
+ let io = test_file_io();
+ let path = "memory:/dv-replacement";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ commit
+ .commit(vec![
+ dv_message("old", "data-a"),
+ dv_message("other", "data-b"),
+ ])
+ .await
+ .unwrap();
+ let mut message = dv_message("new", "data-a");
+ message
+ .deleted_index_files
+ .push(test_deletion_vector_index_file("old", "data-a"));
+ commit.commit(vec![message]).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.commit_kind(), &CommitKind::OVERWRITE);
+ let entries =
+ TableCommit::read_prev_index_entries(&io, &format!("{path}/manifest"),
&Some(snapshot))
+ .await
+ .unwrap();
+ let names = entries
+ .iter()
+ .map(|entry| entry.index_file.file_name.as_str())
+ .collect::<HashSet<_>>();
+ assert_eq!(names, HashSet::from(["new", "other"]));
+}
+
+#[tokio::test]
+async fn unrelated_dv_delete_does_not_bypass_indexed_column_policy() {
+ for action in ["THROW_ERROR", "DROP_PARTITION_INDEX"] {
+ let io = test_file_io();
+ let path = format!("memory:/indexed-update-{action}");
+ setup_dirs(&io, &path).await;
+ let table = test_table_with_options(
+ &io,
+ &path,
+ HashMap::from([(
+ "global-index.column-update-action".to_string(),
+ action.to_string(),
+ )]),
+ );
+ let commit = TableCommit::new(table, "test-user".into());
+ let mut initial = dv_message("old-dv", "data");
+ 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()]);
+ let mut delete = dv_message("new-dv", "data");
+ delete
+ .deleted_index_files
+ .push(test_deletion_vector_index_file("old-dv", "data"));
+ let result = commit.commit(vec![update, delete]).await;
+ if action == "THROW_ERROR" {
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("globally indexed columns"));
+ assert_eq!(latest_snapshot(&io, &path).await.unwrap().id(), 1);
+ } else {
+ result.unwrap();
+ let snapshot = latest_snapshot(&io, &path).await;
+ let entries =
+ TableCommit::read_prev_index_entries(&io,
&format!("{path}/manifest"), &snapshot)
+ .await
+ .unwrap();
+ assert_eq!(entries.len(), 1);
+ assert_eq!(entries[0].index_file.file_name, "new-dv");
+ }
+ }
+}
+
+async fn save_schema(io: &FileIO, path: &str, id: i64) {
+ let mut json = serde_json::to_value(test_schema()).unwrap();
+ json["id"] = id.into();
+ io.new_output(&format!("{path}/schema/schema-{id}"))
+ .unwrap()
+ .write(serde_json::to_vec(&json).unwrap().into())
+ .await
+ .unwrap();
+}
+
+#[tokio::test]
+async fn snapshot_uses_latest_schema_but_files_keep_writer_schema() {
+ let io = test_file_io();
+ let path = "memory:/latest-schema";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ save_schema(&io, path, 1).await;
+ commit
+ .commit(vec![append_message("old-writer.parquet")])
+ .await
+ .unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.schema_id(), 1);
+ assert_eq!(
+ active_entries(&io, path, &snapshot).await[0]
+ .file()
+ .schema_id,
+ 0
+ );
+}
+
+#[tokio::test]
+async fn index_commit_inherits_watermark_and_schema_compatible_statistics() {
+ let io = test_file_io();
+ let path = "memory:/inherited-metadata";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ commit
+ .commit(vec![append_message("data.parquet")])
+ .await
+ .unwrap();
+ let mut initial = serde_json::to_value(latest_snapshot(&io,
path).await.unwrap()).unwrap();
+ initial["watermark"] = 1234.into();
+ initial["statistics"] = "stats-1".into();
+ io.new_output(&format!("{path}/snapshot/snapshot-1"))
+ .unwrap()
+ .write(serde_json::to_vec(&initial).unwrap().into())
+ .await
+ .unwrap();
+ io.new_output(&format!("{path}/statistics/stats-1"))
+ .unwrap()
+ .write(bytes::Bytes::from_static(
+ br#"{"schemaId":0,"snapshotId":1,"colStats":{}}"#,
+ ))
+ .await
+ .unwrap();
+ let mut index = CommitMessage::new(vec![], 0, vec![]);
+ index
+ .new_index_files
+ .push(test_global_index_file("global", 0, 0, 9));
+ commit.commit(vec![index]).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.watermark(), Some(1234));
+ assert_eq!(snapshot.statistics(), Some("stats-1"));
+
+ save_schema(&io, path, 1).await;
+ // Older writers may carry statistics across a schema change. Even if the
+ // snapshot already uses the latest schema, the statistics' own schema
must match.
+ let mut stale_statistics = serde_json::to_value(&snapshot).unwrap();
+ stale_statistics["schemaId"] = 1.into();
+ io.new_output(&format!("{path}/snapshot/snapshot-2"))
+ .unwrap()
+ .write(serde_json::to_vec(&stale_statistics).unwrap().into())
+ .await
+ .unwrap();
+ commit
+ .commit(vec![append_message("new.parquet")])
+ .await
+ .unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.watermark(), Some(1234));
+ assert_eq!(snapshot.statistics(), None);
+}
+
+struct LostResponseCommit {
+ manager: SnapshotManager,
+ calls: std::sync::atomic::AtomicUsize,
+ publish_first: bool,
+}
+
+#[async_trait::async_trait]
+impl SnapshotCommit for LostResponseCommit {
+ async fn commit(&self, snapshot: &Snapshot, _: &[PartitionStatistics]) ->
Result<bool> {
+ let attempt = self.calls.fetch_add(1,
std::sync::atomic::Ordering::SeqCst);
+ if attempt == 0 {
+ if self.publish_first {
+ assert!(self.manager.commit_snapshot(snapshot).await?);
+ }
+ return Err(crate::Error::IoUnsupported {
+ message: "lost commit response".into(),
+ });
+ }
+ self.manager.commit_snapshot(snapshot).await
+ }
+}
+
+#[tokio::test]
+async fn publication_errors_retry_without_duplicate_snapshots() {
+ for publish_first in [false, true] {
+ let io = test_file_io();
+ let path = format!("memory:/publication-error-{publish_first}");
+ setup_dirs(&io, &path).await;
+ let mut commit = setup_commit(&io, &path);
+ commit.commit_min_retry_wait_ms = 0;
+ commit.commit_max_retry_wait_ms = 0;
+ let publisher = Arc::new(LostResponseCommit {
+ manager: commit.snapshot_manager.clone(),
+ calls: std::sync::atomic::AtomicUsize::new(0),
+ publish_first,
+ });
+ commit.snapshot_commit = publisher.clone();
+ commit
+ .commit_with_identifier(vec![append_message("data.parquet")], 42)
+ .await
+ .unwrap();
+ let snapshot = latest_snapshot(&io, &path).await.unwrap();
+ assert_eq!(snapshot.id(), 1);
+ assert_eq!(snapshot.total_record_count(), Some(10));
+ assert_eq!(
+ publisher.calls.load(std::sync::atomic::Ordering::SeqCst),
+ if publish_first { 1 } else { 2 }
+ );
+ assert_eq!(active_entries(&io, &path, &snapshot).await.len(), 1);
+ }
+}
+
+#[tokio::test]
+async fn mixed_append_cannot_bypass_stale_dv_check() {
+ let io = test_file_io();
+ let path = "memory:/mixed-stale-dv";
+ setup_dirs(&io, path).await;
+ let commit = setup_data_evolution_commit(&io, path);
+ for name in ["data", "concurrent"] {
+ let mut message = append_message(name);
+ message.new_files[0].file_source = Some(0);
+ commit.commit(vec![message]).await.unwrap();
+ }
+ let mut delete = dv_message("dv", "data");
+ delete.check_from_snapshot = Some(1);
+ let mut append = append_message("another-file");
+ 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}"
+ );
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 2);
+}
+
+async fn manifest_paths(io: &FileIO, path: &str) -> HashSet<String> {
+ io.list_status(&format!("{path}/manifest/"))
+ .await
+ .unwrap()
+ .into_iter()
+ .filter(|status| !status.is_dir)
+ .map(|status| status.path)
+ .collect()
+}
+
+#[tokio::test]
+async fn preparation_failure_cleans_new_metadata_and_preserves_old_snapshots()
{
+ let io = test_file_io();
+ let path = "memory:/prepare-failure";
+ setup_dirs(&io, path).await;
+ let table = test_table_with_options(
+ &io,
+ path,
+ HashMap::from([("manifest.sidecar.enabled".into(), "true".into())]),
+ );
+ let commit = TableCommit::new(table, "test-user".into());
+ for name in ["first", "second"] {
+ commit.commit(vec![append_message(name)]).await.unwrap();
+ }
+ let before = manifest_paths(&io, path).await;
+ let original = latest_snapshot(&io, path).await.unwrap();
+ let mut value = serde_json::to_value(&original).unwrap();
+ value["statistics"] = "invalid-statistics".into();
+ io.new_output(&format!("{path}/snapshot/snapshot-2"))
+ .unwrap()
+ .write(serde_json::to_vec(&value).unwrap().into())
+ .await
+ .unwrap();
+ io.new_output(&format!("{path}/statistics/invalid-statistics"))
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"broken JSON"))
+ .await
+ .unwrap();
+ let mut message = append_message("pending");
+ message
+ .new_changelog_files
+ .push(test_data_file("changelog", 10));
+ message
+ .new_index_files
+ .push(test_global_index_file("pending-index", 0, 0, 9));
+ let error = commit.commit(vec![message]).await.unwrap_err();
+ assert!(
+ error.to_string().contains("Invalid statistics metadata"),
+ "{error}"
+ );
+ assert_eq!(manifest_paths(&io, path).await, before, "failed preparation
must clean lists, delta/changelog manifests, sidecars and index manifests");
+ assert_eq!(active_entries(&io, path, &original).await.len(), 2);
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 2);
+}
+
+#[derive(Debug)]
+struct FailSecondManifest {
+ operator: opendal::Operator,
+ failed: std::sync::atomic::AtomicBool,
+}
+
+#[async_trait::async_trait]
+impl crate::io::FileIOProvider for FailSecondManifest {
+ async fn create(&self, path: &str) -> Result<(opendal::Operator, String)> {
+ let name = path.rsplit('/').next().unwrap();
+ if name.starts_with("manifest-")
+ && !name.starts_with("manifest-list-")
+ && name.ends_with("-1")
+ && !self.failed.swap(true, std::sync::atomic::Ordering::SeqCst)
+ {
+ return Err(crate::Error::IoUnsupported {
+ message: "injected second manifest write failure".into(),
+ });
+ }
+ Ok((
+ self.operator.clone(),
+ path.trim_start_matches("memory:/").to_string(),
+ ))
+ }
+}
+
+#[tokio::test]
+async fn rolling_manifest_failure_cleans_previous_chunks_and_sidecars() {
+ let provider = Arc::new(FailSecondManifest {
+ operator:
opendal::Operator::new(opendal::services::Memory::default()).unwrap(),
+ failed: std::sync::atomic::AtomicBool::new(false),
+ });
+ let io = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let path = "memory:/rolling-failure";
+ setup_dirs(&io, path).await;
+ let table = test_table_with_options(
+ &io,
+ path,
+ HashMap::from([
+ ("manifest.sidecar.enabled".into(), "true".into()),
+ ("manifest.target-file-size".into(), "1 b".into()),
+ ]),
+ );
+ let commit = TableCommit::new(table, "test-user".into());
+ let files = (0..2001)
+ .map(|id| test_data_file(&format!("{id}.parquet"), 1))
+ .collect();
+ let error = commit
+ .commit(vec![CommitMessage::new(vec![], 0, files)])
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("injected second manifest"),
+ "{error}"
+ );
+ assert!(provider.failed.load(std::sync::atomic::Ordering::SeqCst));
+ assert!(manifest_paths(&io, path).await.is_empty());
+ assert!(latest_snapshot(&io, path).await.is_none());
+}
+
+#[tokio::test]
+async fn uncertain_guarded_commit_does_not_abort_published_index_files() {
+ let io = test_file_io();
+ let path = "memory:/uncertain-index";
+ setup_dirs(&io, path).await;
+ let mut commit = setup_commit(&io, path);
+ commit.commit(vec![append_message("data")]).await.unwrap();
+ commit.commit_max_retries = 0;
+ commit.snapshot_commit = Arc::new(LostResponseCommit {
+ manager: commit.snapshot_manager.clone(),
+ calls: std::sync::atomic::AtomicUsize::new(0),
+ publish_first: true,
+ });
+ let index_path = format!("{path}/index/global");
+ io.new_output(&index_path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"index"))
+ .await
+ .unwrap();
+ let mut index = CommitMessage::new(vec![], 0, vec![]);
+ index
+ .new_index_files
+ .push(test_global_index_file("global", 0, 0, 9));
+ let error = commit
+ .commit_if_latest_snapshot_with_identifier(vec![index.clone()], 1, 42)
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("outcome may be unknown"),
+ "{error}"
+ );
+ assert!(io.exists(&index_path).await.unwrap());
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(snapshot.id(), 2);
+ assert!(snapshot.index_manifest().is_some());
+ commit
+ .filter_and_commit_with_identifier(vec![index], 42)
+ .await
+ .unwrap();
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), 2);
+}
+
+#[tokio::test]
+async fn dv_publication_response_loss_uses_overwrite_identity() {
+ let io = test_file_io();
+ let path = "memory:/dv-response-loss";
+ setup_dirs(&io, path).await;
+ let mut commit = setup_commit(&io, path);
+ commit.commit_min_retry_wait_ms = 0;
+ commit.commit_max_retry_wait_ms = 0;
+ let publisher = Arc::new(LostResponseCommit {
+ manager: commit.snapshot_manager.clone(),
+ calls: std::sync::atomic::AtomicUsize::new(0),
+ publish_first: true,
+ });
+ commit.snapshot_commit = publisher.clone();
+ commit
+ .commit_with_identifier(vec![dv_message("dv", "data")], 7)
+ .await
+ .unwrap();
+ assert_eq!(
+ latest_snapshot(&io, path).await.unwrap().commit_kind(),
+ &CommitKind::OVERWRITE
+ );
+ assert_eq!(publisher.calls.load(std::sync::atomic::Ordering::SeqCst), 1);
+}
+
+#[tokio::test]
+async fn rest_commit_uses_catalog_snapshot_schema_and_retry_identity() {
+ use crate::api::rest_api::RESTApi;
+ use crate::common::Options;
+ use axum::{
+ body::Bytes,
+ http::{Method, Uri},
+ Json, Router,
+ };
+ use std::sync::Mutex;
+ let io = test_file_io();
+ let path = "memory:/rest-commit-parity";
+ setup_dirs(&io, path).await;
+ let seed = setup_commit(&io, path);
+ seed.commit(vec![append_message("original")]).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 handler_snapshot = snapshot.clone();
+ let handler_posts = posts.clone();
+ let app = Router::new().fallback(move |method: Method, uri: Uri, body:
Bytes| {
+ 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") {
+ serde_json::json!({"snapshot": {"snapshot":
*snapshot.lock().unwrap(), "recordCount": 10}})
+ } else {
+ serde_json::json!({"schemaId": 3})
+ };
+ 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(),
+ test_schema(),
+ Some(env),
+ );
+ let mut commit = TableCommit::new(table, "rest-writer".into());
+ commit.commit_min_retry_wait_ms = 0;
+ commit.commit_max_retry_wait_ms = 0;
+ let messages = vec![append_message("new")];
+ commit
+ .commit_if_latest_snapshot_with_identifier(messages.clone(), 7, 42)
+ .await
+ .unwrap();
+ commit
+ .filter_and_commit_with_identifier(messages, 42)
+ .await
+ .unwrap();
+ let latest = snapshot.lock().unwrap().clone();
+ assert_eq!(latest.id(), 8);
+ assert_eq!(latest.schema_id(), 3);
+ assert_eq!(latest.total_record_count(), Some(20));
+ assert_eq!(posts.load(std::sync::atomic::Ordering::SeqCst), 1);
+ assert!(!io
+ .exists(&format!("{path}/snapshot/snapshot-8"))
+ .await
+ .unwrap());
+ assert_eq!(active_entries(&io, path, &latest).await.len(), 2);
+ server.abort();
+}