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 3ba159ca [core] Align commit failure cleanup with Java (#1045)
3ba159ca is described below
commit 3ba159ca31af0308b18e68b46ebf935e9b7b0d45
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 6 16:19:14 2026 +0800
[core] Align commit failure cleanup with Java (#1045)
---
bindings/go/postpone_fixed_bucket_write.go | 3 +-
bindings/go/write.go | 3 +-
bindings/python/README.md | 9 +-
.../python/python/pypaimon_rust/datafusion.pyi | 6 +-
bindings/python/tests/test_table_commit.py | 20 +-
bindings/python/tests/test_update.py | 20 +-
bindings/python/tests/test_write.py | 5 +-
crates/paimon/src/table/commit_message.rs | 40 +++
crates/paimon/src/table/data_evolution_writer.rs | 2 +
crates/paimon/src/table/format_table_commit.rs | 114 ++++---
.../paimon/src/table/format_table_write_tests.rs | 368 ++++++++++++++++++++-
crates/paimon/src/table/format_table_writer.rs | 14 +-
.../src/table/lumina_index_build_builder/writer.rs | 1 +
.../src/table/sorted_global_index_build_builder.rs | 2 +
crates/paimon/src/table/table_commit.rs | 54 ++-
.../paimon/src/table/table_commit/abort_tests.rs | 121 +++++++
crates/paimon/src/table/table_update.rs | 19 +-
crates/paimon/src/table/table_update_by_row_id.rs | 23 +-
crates/paimon/src/table/table_update_predicate.rs | 8 +-
crates/paimon/src/table/table_upsert.rs | 11 +-
crates/paimon/src/table/table_write.rs | 19 +-
.../paimon/src/table/vindex_index_build_builder.rs | 2 +
crates/paimon/tests/dynamic_bucket_write_test.rs | 87 ++++-
crates/paimon/tests/table_update_test.rs | 75 ++++-
docs/src/getting-started.md | 16 +-
25 files changed, 883 insertions(+), 159 deletions(-)
diff --git a/bindings/go/postpone_fixed_bucket_write.go
b/bindings/go/postpone_fixed_bucket_write.go
index 24ea290b..d3445c16 100644
--- a/bindings/go/postpone_fixed_bucket_write.go
+++ b/bindings/go/postpone_fixed_bucket_write.go
@@ -344,7 +344,8 @@ func (tc *PostponeFixedBucketTableCommit)
TruncateTableWithIdentifier(
)
}
-// Abort performs best-effort cleanup of files in prepared messages.
+// Abort deletes files for an explicitly abandoned, known-uncommitted write.
+// Never call when the commit outcome is unknown.
func (tc *PostponeFixedBucketTableCommit) Abort(
messages *PostponeFixedBucketCommitMessages,
) error {
diff --git a/bindings/go/write.go b/bindings/go/write.go
index c5b793ab..7ae14693 100644
--- a/bindings/go/write.go
+++ b/bindings/go/write.go
@@ -304,7 +304,8 @@ func (tc *TableCommit)
TruncateTableWithIdentifier(commitIdentifier int64) error
return
ffiTableCommitTruncateTableWithIdentifier.symbol(tc.ctx)(tc.inner,
commitIdentifier)
}
-// Abort performs best-effort cleanup of files created for a prepared commit.
+// Abort deletes files for an explicitly abandoned, known-uncommitted write.
+// Never call when the commit outcome is unknown.
func (tc *TableCommit) Abort(messages *CommitMessages) error {
return tc.withMessages(messages, ffiTableCommitAbort.symbol(tc.ctx))
}
diff --git a/bindings/python/README.md b/bindings/python/README.md
index 56613434..ad3230c4 100644
--- a/bindings/python/README.md
+++ b/bindings/python/README.md
@@ -197,8 +197,13 @@ mode. `CommitMessage.deserialize(body, version=14)`
decodes it without a table
or builder. Submit decoded messages to their originating table; commit identity
and overwrite mode come from the configured committer. Messages returned
directly
by local writers retain their table and commit-user checks.
-Only v14 is supported. `abort(messages)` deletes newly written files and must
-only be used for messages known not to have committed. Compact increments
remain
+Only v14 is supported. `abort(messages)` deletes newly written files for an
+explicitly abandoned write, matching Java. Call it only for messages known to
be
+uncommitted that will never be submitted. A commit exception does not prove
failure:
+publication may have succeeded before the response failed. Internal error paths
+preserve submitted files instead of calling `abort`. Preparation failures can
clean
+outputs that have not been handed off to the caller.
+Compact increments remain
unsupported by the Rust committer and are rejected.
### Tables resolved outside the Rust catalog
diff --git a/bindings/python/python/pypaimon_rust/datafusion.pyi
b/bindings/python/python/pypaimon_rust/datafusion.pyi
index 2953e1c5..fcfe3c58 100644
--- a/bindings/python/python/pypaimon_rust/datafusion.pyi
+++ b/bindings/python/python/pypaimon_rust/datafusion.pyi
@@ -308,7 +308,7 @@ class BatchTableCommit:
"""Truncate matching partitions. Reject empty input, as Java does."""
...
def abort(self, messages: Sequence[CommitMessage]) -> None:
- """Best-effort deletion of newly written files; only abort uncommitted
messages."""
+ """Delete known-uncommitted files; never call when the commit outcome
is unknown."""
...
class StreamTableCommit:
@@ -321,7 +321,9 @@ class StreamTableCommit:
) -> int:
"""Sort identifiers, skip committed groups, and return the number
committed."""
...
- def abort(self, messages: Sequence[CommitMessage]) -> None: ...
+ def abort(self, messages: Sequence[CommitMessage]) -> None:
+ """Delete known-uncommitted files; never call when the commit outcome
is unknown."""
+ ...
class BatchWriteBuilder:
def _with_commit_user(self, commit_user: str) -> "BatchWriteBuilder":
diff --git a/bindings/python/tests/test_table_commit.py
b/bindings/python/tests/test_table_commit.py
index 2af7a926..00725b0e 100644
--- a/bindings/python/tests/test_table_commit.py
+++ b/bindings/python/tests/test_table_commit.py
@@ -185,15 +185,25 @@ def
test_invalid_batch_bridge_commit_user_preserves_identity(tmp_path, user):
assert _rows(table) == [1]
-def test_abort_serialized_messages_deletes_files(tmp_path):
- table = _table(tmp_path)
- builder = table.new_batch_write_builder()
- messages = _roundtrip(_prepare(builder, [1], [10]))
[email protected]('stream', [False, True])
[email protected]('primary_key', [False, True])
[email protected]('serialized', [False, True])
+def test_explicit_abort_deletes_known_uncommitted_files(tmp_path, stream,
primary_key, serialized):
+ table = _table(tmp_path, primary_key=primary_key, options={'bucket': '1'}
if primary_key else None)
+ builder = table.new_stream_write_builder() if stream else
table.new_batch_write_builder()
+ messages = _prepare(builder, [1], [10], 7 if stream else None)
+ if serialized:
+ messages = _roundtrip(messages)
files = list(tmp_path.rglob("data-*.parquet"))
assert files
- builder.new_commit().abort(messages)
+ assert table.latest_snapshot() is None
+ commit = builder.new_commit()
+ # These messages have never been submitted and will not be committed.
+ commit.abort(messages)
+ commit.abort(messages)
assert not any(file.exists() for file in files)
assert table.latest_snapshot() is None
+ assert _rows(table) == []
def test_batch_writer_and_commit_are_one_shot(tmp_path):
diff --git a/bindings/python/tests/test_update.py
b/bindings/python/tests/test_update.py
index 2e7322ec..33bf8a09 100644
--- a/bindings/python/tests/test_update.py
+++ b/bindings/python/tests/test_update.py
@@ -172,13 +172,16 @@ def
test_grouped_batch_update_checks_input_table_file_overlap(tmp_path):
'grouped_updates.t')
before = set(tmp_path.rglob('*.parquet'))
+ snapshot_id = table.latest_snapshot().id()
overlap = table.new_batch_write_builder().new_update()
with pytest.raises(ValueError, match='overlapping first_row_ids.*0'):
overlap.update_by_arrow_batches_with_row_id(iter([
pa.table({'_ROW_ID': [0], 'name': ['A']}),
pa.table({'_ROW_ID': [1], 'name': ['B']}),
]))
- assert set(tmp_path.rglob('*.parquet')) == before
+ assert set(tmp_path.rglob('*.parquet')) > before
+ assert table.latest_snapshot().id() == snapshot_id
+ before = set(tmp_path.rglob('*.parquet'))
def failing_tables():
yield pa.table({'_ROW_ID': [0], 'name': ['A']})
@@ -186,7 +189,11 @@ def
test_grouped_batch_update_checks_input_table_file_overlap(tmp_path):
with pytest.raises(RuntimeError, match='input failed'):
overlap.update_by_arrow_batches_with_row_id(failing_tables())
- assert set(tmp_path.rglob('*.parquet')) == before
+ assert set(tmp_path.rglob('*.parquet')) > before
+ assert table.latest_snapshot().id() == snapshot_id
+ unchanged = pa.Table.from_batches(context.sql(
+ 'SELECT id, name FROM
paimon.grouped_updates.t')).sort_by('id').to_pydict()
+ assert unchanged == {'id': [1, 2, 3, 4], 'name': ['a', 'b', 'c', 'd']}
builder = table.new_batch_write_builder()
messages = builder.new_update().update_by_arrow_batches_with_row_id(iter([
@@ -271,7 +278,7 @@ def test_stream_row_id_update_and_factory(tmp_path):
assert actual == {'id': [1, 2], 'value': [11, 22]}
-def test_predicate_update_owns_scan_callbacks_and_rollback(tmp_path):
+def
test_predicate_update_owns_scan_callbacks_and_preserves_prepared_files(tmp_path):
assert not hasattr(datafusion, '_MatchedBatchUpdateWriter')
context = SQLContext()
context.register_catalog('paimon', {'warehouse': str(tmp_path)})
@@ -303,6 +310,7 @@ def
test_predicate_update_owns_scan_callbacks_and_rollback(tmp_path):
'score': [100, 999, 999, 999]}
before = set(tmp_path.rglob('*.parquet'))
+ snapshot_id = table.latest_snapshot().id()
seen.clear()
def fail_second(rows):
@@ -314,7 +322,11 @@ def
test_predicate_update_owns_scan_callbacks_and_rollback(tmp_path):
with pytest.raises(RuntimeError, match='callback failure'):
update.update_by_predicate(None, {'value': fail_second},
read_columns=['value'])
assert len(seen) == 2
- assert set(tmp_path.rglob('*.parquet')) == before
+ assert set(tmp_path.rglob('*.parquet')) > before
+ assert table.latest_snapshot().id() == snapshot_id
+ unchanged = pa.Table.from_batches(context.sql(
+ 'SELECT id, value, score FROM
paimon.pred_updates.t')).sort_by('id').to_pydict()
+ assert unchanged == actual
assert update.update_by_predicate(
{'method': 'equal', 'field': 'id', 'literals': [99]},
{'value': 'bad-int'}) == []
diff --git a/bindings/python/tests/test_write.py
b/bindings/python/tests/test_write.py
index 657965fc..59e256a3 100644
--- a/bindings/python/tests/test_write.py
+++ b/bindings/python/tests/test_write.py
@@ -206,9 +206,8 @@ def test_commit_different_builder_same_table_raises():
table.new_batch_write_builder().new_commit().commit(messages)
-def test_abort_cleans_up_written_data():
- # Write data, prepare commit, abort — the written files should be deleted
- # and reading back should return zero rows.
+def test_abort_cleans_up_abandoned_data():
+ # Explicitly discard prepared messages that have never been committed.
with tempfile.TemporaryDirectory() as warehouse:
ctx = _make_empty_table(warehouse)
table = _get_table(warehouse)
diff --git a/crates/paimon/src/table/commit_message.rs
b/crates/paimon/src/table/commit_message.rs
index 29c904d1..db24ee9c 100644
--- a/crates/paimon/src/table/commit_message.rs
+++ b/crates/paimon/src/table/commit_message.rs
@@ -276,6 +276,46 @@ pub(crate) struct FormatFileCommit {
pub partition: HashMap<String, String>,
pub record_count: i64,
pub file_size: i64,
+ // Java TwoPhaseCommitMessage keeps this on the message so a later abort
+ // instance makes the same decision. Cloned Rust messages share the flag.
+ preserve_published_target_on_abort:
std::sync::Arc<std::sync::atomic::AtomicBool>,
+}
+
+impl FormatFileCommit {
+ pub(crate) fn new(
+ staged_path: String,
+ target_path: String,
+ partition: HashMap<String, String>,
+ record_count: i64,
+ file_size: i64,
+ ) -> Self {
+ Self {
+ staged_path,
+ target_path,
+ partition,
+ record_count,
+ file_size,
+ // Until publication starts the target is not owned by this write.
+ preserve_published_target_on_abort: std::sync::Arc::new(
+ std::sync::atomic::AtomicBool::new(true),
+ ),
+ }
+ }
+
+ pub(crate) fn preserve_published_target_on_abort(&self) {
+ self.preserve_published_target_on_abort
+ .store(true, std::sync::atomic::Ordering::Relaxed);
+ }
+
+ pub(crate) fn allow_published_target_cleanup(&self) {
+ self.preserve_published_target_on_abort
+ .store(false, std::sync::atomic::Ordering::Relaxed);
+ }
+
+ pub(crate) fn should_preserve_published_target_on_abort(&self) -> bool {
+ self.preserve_published_target_on_abort
+ .load(std::sync::atomic::Ordering::Relaxed)
+ }
}
#[cfg(test)]
diff --git a/crates/paimon/src/table/data_evolution_writer.rs
b/crates/paimon/src/table/data_evolution_writer.rs
index 0e82ade7..2647bff2 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -659,6 +659,8 @@ impl DataEvolutionDeleteWriter {
Ok(Some(message)) => messages.push(message),
Ok(None) => {}
Err(error) => {
+ // Earlier outputs are private to this preparation; nothing
+ // has been returned to a caller or submitted for commit.
let _ = self
.table
.new_write_builder()
diff --git a/crates/paimon/src/table/format_table_commit.rs
b/crates/paimon/src/table/format_table_commit.rs
index ee4a7822..511b3ddc 100644
--- a/crates/paimon/src/table/format_table_commit.rs
+++ b/crates/paimon/src/table/format_table_commit.rs
@@ -16,9 +16,9 @@
// under the License.
//! Publish prepared Format Table files without creating a Paimon snapshot.
-//! Java's `FormatTableCommit` treats published data and catalog partition
-//! registration as separate side effects, with different rollback rules before
-//! and after a partition becomes visible to readers.
+//! Published data and catalog partition registration are separate side
effects.
+//! Follow Java two-phase abort: discard staging, roll back append targets
before
+//! registration completes, and protect overwrite replacements and registered
files.
use std::collections::{HashMap, HashSet};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -74,12 +74,32 @@ impl<'a> FormatTableCommit<'a> {
}
pub(crate) async fn abort(&self, messages: &[CommitMessage]) -> Result<()>
{
+ let mut failure = None;
for message in messages {
- if let Some(file) = &message.format_file {
- self.table.file_io().delete_file(&file.staged_path).await?;
+ let Some(file) = &message.format_file else {
+ failure.get_or_insert_with(|| crate::Error::DataInvalid {
+ message: "Format Table abort requires staged file
messages".into(),
+ source: None,
+ });
+ continue;
+ };
+ // Staging belongs to this write; a preflight failure must never
+ // remove a pre-existing target. Keep processing after cleanup
errors.
+ if file
+ .staged_path
+ .starts_with(&format!("{}/_temporary/", self.table_path))
+ {
+ if let Err(error) =
self.table.file_io().delete_file(&file.staged_path).await {
+ failure.get_or_insert(error);
+ }
+ }
+ if !file.should_preserve_published_target_on_abort() {
+ if let Err(error) =
self.table.file_io().delete_file(&file.target_path).await {
+ failure.get_or_insert(error);
+ }
}
}
- Ok(())
+ failure.map_or(Ok(()), Err)
}
/// `None` is append; `Some(None)` is an overwrite without static
@@ -89,9 +109,21 @@ impl<'a> FormatTableCommit<'a> {
messages: &[CommitMessage],
overwrite: Option<Option<&HashMap<String, Option<Datum>>>>,
) -> Result<()> {
+ // Validation can encounter a target from an earlier attempt or another
+ // writer. Until this attempt starts publishing, only staging is ours.
+ for file in messages
+ .iter()
+ .filter_map(|message| message.format_file.as_ref())
+ {
+ file.preserve_published_target_on_abort();
+ }
let result = self.apply_inner(messages, overwrite).await;
if result.is_err() {
- let _ = self.abort(messages).await;
+ // Java FormatTableCommit rolls back append publication, but
retains
+ // overwrite replacements and successfully registered append files.
+ if let Err(error) = self.abort(messages).await {
+ log::warn!("Failed to abort Format Table files after commit
failure: {error}");
+ }
}
result
}
@@ -159,24 +191,17 @@ impl<'a> FormatTableCommit<'a> {
}
}
- let mut published: Vec<String> = Vec::new();
- for file in &files {
- let result = self.publish(file).await;
- if let Err(error) = result {
- // An overwrite has already removed old files, so replacement
- // files must survive an uncertain partial publish. Append can
- // safely roll back files this attempt uniquely named.
- if overwrite.is_none() {
- for path in
published.iter().chain(std::iter::once(&file.target_path)) {
- let _ = self.table.file_io().delete_file(path).await;
- }
- }
- self.discard_staging(&files).await;
- return Err(error);
+ if overwrite.is_some() {
+ // Old data has been removed. Java protects every replacement
target
+ // before publishing, including a later explicit abort instance.
+ for file in &files {
+ file.preserve_published_target_on_abort();
}
- published.push(file.target_path.clone());
}
- self.discard_staging(&files).await;
+ for file in &files {
+ self.publish(file, overwrite.is_some()).await?;
+ }
+ self.discard_staging(&files).await?;
if managed {
let stats = self.partition_statistics(&files, &selected,
overwrite.is_some());
@@ -212,17 +237,14 @@ impl<'a> FormatTableCommit<'a> {
)
.await?;
} else {
- // Java registers first, then preserves visible files even
- // if the later additive statistics report fails.
- if let Err(error) = env
- .api()
+ // Java registers append partitions before protecting their
+ // targets. Registration failure rolls back this attempt's
+ // files, even if registration succeeded before a lost
response.
+ env.api()
.create_partitions(env.identifier(), specs.clone(),
true)
- .await
- {
- for path in &published {
- let _ =
self.table.file_io().delete_file(path).await;
- }
- return Err(error);
+ .await?;
+ for file in &files {
+ file.preserve_published_target_on_abort();
}
if let Err(error) = env
.api()
@@ -375,7 +397,7 @@ impl<'a> FormatTableCommit<'a> {
})
}
- async fn publish(&self, file: &FormatFileCommit) -> Result<()> {
+ async fn publish(&self, file: &FormatFileCommit, overwrite: bool) ->
Result<()> {
let directory = self.partition_directory(&file.partition)?;
self.table
.file_io()
@@ -390,6 +412,11 @@ impl<'a> FormatTableCommit<'a> {
source: None,
});
}
+ if !overwrite {
+ // Like Java's overwrite=false two-phase writer, the new UUID
target
+ // belongs to this append once publication starts and can be
rolled back.
+ file.allow_published_target_cleanup();
+ }
match self
.table
.file_io()
@@ -400,24 +427,25 @@ impl<'a> FormatTableCommit<'a> {
Err(crate::Error::IoUnexpected { source, .. })
if source.kind() == opendal::ErrorKind::Unsupported =>
{
- let result = self
- .table
+ // Abort uses the message's append/overwrite protection even if
+ // a copy finishes before its response fails.
+ self.table
.file_io()
.copy_file_streaming(&file.staged_path, &file.target_path)
- .await;
- if result.is_err() {
- let _ =
self.table.file_io().delete_file(&file.target_path).await;
- }
- result
+ .await
}
Err(error) => Err(error),
}
}
- async fn discard_staging(&self, files: &[FormatFileCommit]) {
+ async fn discard_staging(&self, files: &[FormatFileCommit]) -> Result<()> {
+ let mut failure = None;
for file in files {
- let _ = self.table.file_io().delete_file(&file.staged_path).await;
+ if let Err(error) =
self.table.file_io().delete_file(&file.staged_path).await {
+ failure.get_or_insert(error);
+ }
}
+ failure.map_or(Ok(()), Err)
}
async fn selected_overwrite_partitions(
diff --git a/crates/paimon/src/table/format_table_write_tests.rs
b/crates/paimon/src/table/format_table_write_tests.rs
index f9bbe50e..94baa8e8 100644
--- a/crates/paimon/src/table/format_table_write_tests.rs
+++ b/crates/paimon/src/table/format_table_write_tests.rs
@@ -357,6 +357,369 @@ async fn
prepared_files_are_hidden_until_commit_and_abort_discards_them() {
assert!(visible_files(&table, "dt=a").await.is_empty());
}
+#[tokio::test]
+async fn invalid_commit_discards_staging_without_deleting_foreign_targets() {
+ let table = memory_table("format_invalid_commit_cleanup", true, &[]);
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch(&[("a", 1)])).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let file = messages[0].format_file.as_ref().unwrap();
+ let outside = "memory:/outside-table/file.parquet";
+ table
+ .file_io()
+ .new_output(outside)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"foreign"))
+ .await
+ .unwrap();
+ let mut invalid = messages.clone();
+ invalid[0].format_file.as_mut().unwrap().target_path = outside.into();
+ assert!(builder
+ .new_commit()
+ .commit(invalid.clone())
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("outside partition"));
+ assert!(!table.file_io().exists(&file.staged_path).await.unwrap());
+ assert!(visible_files(&table, "dt=a").await.is_empty());
+ builder.new_commit().abort(&invalid).await.unwrap();
+ assert_eq!(
+ table
+ .file_io()
+ .new_input(outside)
+ .unwrap()
+ .read()
+ .await
+ .unwrap(),
+ bytes::Bytes::from_static(b"foreign")
+ );
+ append(&table, &batch(&[("a", 2)])).await;
+ assert_eq!(ids(&table).await, [2]);
+}
+
+#[derive(Debug)]
+struct FailStagedAccess {
+ operator: opendal::Operator,
+ staged_path: std::sync::Mutex<String>,
+ accesses: std::sync::atomic::AtomicUsize,
+ fail_at: usize,
+}
+
+#[async_trait::async_trait]
+impl crate::io::FileIOProvider for FailStagedAccess {
+ async fn create(&self, path: &str) -> crate::Result<(opendal::Operator,
String)> {
+ // The first access validates staging; the second starts publication.
+ if path == *self.staged_path.lock().unwrap()
+ && self
+ .accesses
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ == self.fail_at
+ {
+ return Err(crate::Error::IoUnsupported {
+ message: "staged I/O failed".into(),
+ });
+ }
+ Ok((
+ self.operator.clone(),
+ path.trim_start_matches("memory:/").into(),
+ ))
+ }
+}
+
+#[tokio::test]
+async fn publication_and_staging_cleanup_failures_follow_java_abort() {
+ for overwrite in [false, true] {
+ for fail_at in [1, 2] {
+ // Use a rename-capable backend so access 2 is staging cleanup,
+ // independently of the streaming-copy fallback's extra reads.
+ let temp = tempfile::tempdir().unwrap();
+ let mut config = opendal_service_fs::FsConfig::default();
+ config.root = Some(temp.path().display().to_string());
+ let provider = Arc::new(FailStagedAccess {
+ operator: opendal::Operator::from_config(config).unwrap(),
+ staged_path: std::sync::Mutex::new(String::new()),
+ accesses: std::sync::atomic::AtomicUsize::new(0),
+ fail_at,
+ });
+ let io = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let table = table(io, "memory:/format_failed_publish", true, &[]);
+ append(&table, &batch(&[("a", 9), ("b", 10)])).await;
+ let mut builder = table.new_write_builder();
+ if overwrite {
+ builder = builder.with_overwrite();
+ }
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&batch(&[("a", 1), ("b", 2)]))
+ .await
+ .unwrap();
+ let mut messages = writer.prepare_commit().await.unwrap();
+ messages.sort_by_key(|message| {
+ message.format_file.as_ref().unwrap().partition["dt"].clone()
+ });
+ let first = messages[0].format_file.as_ref().unwrap().clone();
+ let second = messages[1].format_file.as_ref().unwrap().clone();
+ *provider.staged_path.lock().unwrap() = second.staged_path.clone();
+ let commit = builder.new_commit();
+ let result = if overwrite {
+ commit.overwrite(messages.clone(), None).await
+ } else {
+ commit.commit(messages.clone()).await
+ };
+ let error = result.unwrap_err();
+ assert!(error.to_string().contains("staged I/O failed"),
"{error}");
+ assert_eq!(
+ table.file_io().exists(&first.target_path).await.unwrap(),
+ overwrite
+ );
+ assert_eq!(
+ table.file_io().exists(&second.target_path).await.unwrap(),
+ overwrite && fail_at == 2
+ );
+ for file in [&first, &second] {
+
assert!(!table.file_io().exists(&file.staged_path).await.unwrap());
+ }
+ // A new committer sees protection through cloned messages, as in
Java.
+ builder.new_commit().abort(&messages).await.unwrap();
+ let expected = if !overwrite {
+ vec![9, 10]
+ } else if fail_at == 1 {
+ vec![1]
+ } else {
+ vec![1, 2]
+ };
+ assert_eq!(ids(&table).await, expected);
+ }
+ }
+}
+
+#[tokio::test]
+async fn
catalog_registration_controls_append_rollback_and_overwrite_protection() {
+ use crate::{Options, RESTApi};
+ use axum::http::{Method, StatusCode, Uri};
+ use axum::response::IntoResponse;
+ use axum::{Json, Router};
+ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+
+ for overwrite in [false, true] {
+ for (fail_registration, fail_statistics) in [(true, false), (false,
false), (false, true)] {
+ let registered = Arc::new(AtomicBool::new(false));
+ let server_registered = registered.clone();
+ let calls = Arc::new(AtomicUsize::new(0));
+ let app = Router::new().fallback(move |method: Method, uri: Uri| {
+ let registered = server_registered.clone();
+ let calls = calls.clone();
+ async move {
+ if method == Method::POST &&
uri.path().ends_with("/partitions") {
+ let call = calls.fetch_add(1, Ordering::SeqCst);
+ registered.store(true, Ordering::SeqCst);
+ // Registration/statistics took effect but the
response is undecodable.
+ if (call == 0 && fail_registration) || (call > 0 &&
fail_statistics) {
+ return (StatusCode::OK, "lost catalog
response").into_response();
+ }
+ return Json(serde_json::json!({})).into_response();
+ }
+ let partitions = if registered.load(Ordering::SeqCst) {
+ serde_json::json!([{"spec": {"dt": "a"}}])
+ } else {
+ serde_json::json!([])
+ };
+ Json(serde_json::json!({"partitions":
partitions})).into_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("default", "format_abort");
+ let cache =
crate::io::FileIOCacheContext::from_props(options.to_map()).unwrap();
+ let env = super::RESTEnv::new(
+ identifier.clone(),
+ "uuid".into(),
+ api,
+ options,
+ false,
+ None,
+ cache,
+ );
+ let base = memory_table(
+ "format_catalog_abort",
+ true,
+ &[("metastore.partitioned-table", "true")],
+ );
+ let table = Table::new(
+ base.file_io().clone(),
+ identifier,
+ base.location().into(),
+ base.schema().clone(),
+ Some(env),
+ );
+ let mut builder = table.new_write_builder();
+ if overwrite {
+ builder = builder.with_overwrite();
+ }
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch(&[("a", 1)])).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let file = messages[0].format_file.as_ref().unwrap().clone();
+ let commit = builder.new_commit();
+ let result = if overwrite {
+ commit.overwrite(messages.clone(), None).await
+ } else {
+ commit.commit(messages.clone()).await
+ };
+ assert_eq!(result.is_err(), fail_registration, "{result:?}");
+ assert!(registered.load(Ordering::SeqCst));
+ let retain = overwrite || !fail_registration;
+ assert_eq!(
+ table.file_io().exists(&file.target_path).await.unwrap(),
+ retain
+ );
+ assert!(!table.file_io().exists(&file.staged_path).await.unwrap());
+ builder.new_commit().abort(&messages).await.unwrap();
+ assert_eq!(
+ table.file_io().exists(&file.target_path).await.unwrap(),
+ retain
+ );
+ assert_eq!(ids(&table).await, if retain { vec![1] } else { vec![]
});
+ server.abort();
+ }
+ }
+}
+
+#[derive(Debug)]
+struct LosePublicationResponse {
+ operator: opendal::Operator,
+ paths: std::sync::Mutex<(String, String)>,
+ accesses: std::sync::atomic::AtomicUsize,
+}
+
+#[async_trait::async_trait]
+impl crate::io::FileIOProvider for LosePublicationResponse {
+ async fn create(&self, path: &str) -> crate::Result<(opendal::Operator,
String)> {
+ let (staged, target) = self.paths.lock().unwrap().clone();
+ if path == staged
+ && self
+ .accesses
+ .fetch_add(1, std::sync::atomic::Ordering::SeqCst)
+ == 1
+ {
+ // The target became visible, but the caller receives an exception.
+ let data = self
+ .operator
+ .read(staged.trim_start_matches("memory:/"))
+ .await
+ .map_err(|error|
crate::Error::from_opendal_with_context(error, "read staging"))?;
+ self.operator
+ .write(target.trim_start_matches("memory:/"), data)
+ .await
+ .map_err(|error| {
+ crate::Error::from_opendal_with_context(error, "publish
target")
+ })?;
+ return Err(crate::Error::IoUnsupported {
+ message: "lost publication response".into(),
+ });
+ }
+ Ok((
+ self.operator.clone(),
+ path.trim_start_matches("memory:/").into(),
+ ))
+ }
+}
+
+#[tokio::test]
+async fn
lost_file_publication_response_rolls_back_append_and_preserves_overwrite() {
+ for overwrite in [false, true] {
+ let provider = Arc::new(LosePublicationResponse {
+ operator:
opendal::Operator::new(opendal::services::Memory::default()).unwrap(),
+ paths: std::sync::Mutex::new((String::new(), String::new())),
+ accesses: std::sync::atomic::AtomicUsize::new(0),
+ });
+ let io = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let table = table(io, "memory:/format_lost_file_response", true, &[]);
+ append(&table, &batch(&[("a", 9)])).await;
+ let mut builder = table.new_write_builder();
+ if overwrite {
+ builder = builder.with_overwrite();
+ }
+ let mut writer = builder.new_write().unwrap();
+ writer.write_arrow_batch(&batch(&[("a", 1)])).await.unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let file = messages[0].format_file.as_ref().unwrap();
+ *provider.paths.lock().unwrap() = (file.staged_path.clone(),
file.target_path.clone());
+ let commit = builder.new_commit();
+ let result = if overwrite {
+ commit.overwrite(messages.clone(), None).await
+ } else {
+ commit.commit(messages.clone()).await
+ };
+ assert!(result
+ .unwrap_err()
+ .to_string()
+ .contains("lost publication response"));
+ assert_eq!(
+ table.file_io().exists(&file.target_path).await.unwrap(),
+ overwrite
+ );
+ assert!(!table.file_io().exists(&file.staged_path).await.unwrap());
+ builder.new_commit().abort(&messages).await.unwrap();
+ assert_eq!(ids(&table).await, if overwrite { vec![1] } else { vec![9]
});
+ }
+}
+
+#[tokio::test]
+async fn abort_attempts_every_staged_file_after_cleanup_failure() {
+ let provider = Arc::new(FailStagedAccess {
+ operator:
opendal::Operator::new(opendal::services::Memory::default()).unwrap(),
+ staged_path: std::sync::Mutex::new(String::new()),
+ accesses: std::sync::atomic::AtomicUsize::new(0),
+ fail_at: 0,
+ });
+ let io = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let table = table(io, "memory:/format_abort_failure", true, &[]);
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&batch(&[("a", 1), ("b", 2)]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let first = messages[0].format_file.as_ref().unwrap();
+ let second = messages[1].format_file.as_ref().unwrap();
+ for file in [first, second] {
+ table
+ .file_io()
+ .copy_file_streaming(&file.staged_path, &file.target_path)
+ .await
+ .unwrap();
+ }
+ *provider.staged_path.lock().unwrap() = first.staged_path.clone();
+ let error = builder.new_commit().abort(&messages).await.unwrap_err();
+ assert!(error.to_string().contains("staged I/O failed"));
+ assert!(table.file_io().exists(&first.staged_path).await.unwrap());
+ assert!(!table.file_io().exists(&second.staged_path).await.unwrap());
+ builder.new_commit().abort(&messages).await.unwrap();
+ for file in [first, second] {
+ assert!(!table.file_io().exists(&file.staged_path).await.unwrap());
+ assert!(table.file_io().exists(&file.target_path).await.unwrap());
+ }
+}
+
#[tokio::test]
async fn closing_a_writer_discards_pending_data_without_publishing() {
let table = memory_table("format_close_pending", true, &[]);
@@ -500,6 +863,7 @@ async fn
target_collision_is_rejected_before_any_overwrite_cleanup() {
.unwrap();
assert!(error.to_string().contains("target already exists"));
assert_eq!(visible_files(&table, "dt=a").await, old_files);
+ assert!(!table.file_io().exists(&file.staged_path).await.unwrap());
builder.new_commit().abort(&messages).await.unwrap();
assert_eq!(visible_files(&table, "dt=a").await, old_files);
}
@@ -536,7 +900,7 @@ async fn
format_file_message_refuses_snapshot_wire_serialization() {
let messages = write.prepare_commit().await.unwrap();
let error = messages[0].serialize().err().unwrap();
assert!(error.to_string().contains("different Java serializer"));
- builder.new_commit().abort(&messages).await.unwrap();
+ // Preserve files after publication errors; explicit abort requires
known-uncommitted messages.
}
#[tokio::test]
@@ -642,7 +1006,7 @@ async fn
output_outside_static_prefix_is_rejected_before_deleting_old_files() {
.unwrap();
assert!(error.to_string().contains("outside the static overwrite"));
assert_eq!(ids(&table).await, [1]);
- builder.new_commit().abort(&messages).await.unwrap();
+ // Preserve files after publication errors; explicit abort requires
known-uncommitted messages.
}
#[tokio::test]
diff --git a/crates/paimon/src/table/format_table_writer.rs
b/crates/paimon/src/table/format_table_writer.rs
index 0d3e10c3..09aada67 100644
--- a/crates/paimon/src/table/format_table_writer.rs
+++ b/crates/paimon/src/table/format_table_writer.rs
@@ -427,13 +427,13 @@ impl FormatTableWriter {
.await?
.size as i64;
let mut message = CommitMessage::new(key.to_vec(), 0, Vec::new());
- message.format_file = Some(FormatFileCommit {
- staged_path: file.staged_path.clone(),
- target_path: file.target_path,
- partition: partition.spec.clone(),
- record_count: file.record_count,
- file_size: size,
- });
+ message.format_file = Some(FormatFileCommit::new(
+ file.staged_path.clone(),
+ file.target_path,
+ partition.spec.clone(),
+ file.record_count,
+ size,
+ ));
self.owned_staging.remove(&file.staged_path);
self.prepared.push(message);
Ok(())
diff --git a/crates/paimon/src/table/lumina_index_build_builder/writer.rs
b/crates/paimon/src/table/lumina_index_build_builder/writer.rs
index 95e8fdda..1cb61468 100644
--- a/crates/paimon/src/table/lumina_index_build_builder/writer.rs
+++ b/crates/paimon/src/table/lumina_index_build_builder/writer.rs
@@ -120,6 +120,7 @@ pub(super) async fn abort_on_build_error<T>(
match result {
Ok(value) => Ok(value),
Err(error) => {
+ // This build has not submitted or exposed any prepared shard.
let _ = commit.abort(messages).await;
Err(error)
}
diff --git a/crates/paimon/src/table/sorted_global_index_build_builder.rs
b/crates/paimon/src/table/sorted_global_index_build_builder.rs
index d8619424..043dd3bf 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder.rs
@@ -231,6 +231,8 @@ impl<'a> SortedGlobalIndexBuildBuilder<'a> {
{
Ok(index_file) => index_file,
Err(error) => {
+ // Earlier outputs are private to this preparation; nothing
+ // has been returned to a caller or submitted for commit.
let _ = commit.abort(&messages).await;
return Err(error);
}
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 5ad2d51f..2b65486b 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -373,6 +373,8 @@ impl TableCommit {
let changelog_entries =
self.messages_to_changelog_entries(&commit_messages);
let new_index_entries =
self.messages_to_index_entries(&commit_messages);
let check_from_snapshot = Self::check_from_snapshot(&commit_messages)?;
+ // Never delete CommitMessage files on failure. A previous attempt may
+ // have published them even when the caller received an exception.
self.try_commit(
CommitEntriesPlan::Direct {
entries,
@@ -421,28 +423,18 @@ impl TableCommit {
let changelog_entries =
self.messages_to_changelog_entries(&commit_messages);
let new_index_entries =
self.messages_to_index_entries(&commit_messages);
let check_from_snapshot = Self::check_from_snapshot(&commit_messages)?;
- let result = self
- .try_commit(
- CommitEntriesPlan::Direct {
- entries,
- changelog_entries,
- new_index_entries,
- check_from_snapshot,
- },
- Some(expected_snapshot_id),
- commit_identifier,
- false,
- )
- .await;
- if let Err(error) = result {
- // Storage and REST errors can be indeterminate: the snapshot may
- // already reference these files even though the response failed.
- if matches!(&error, crate::Error::DataInvalid { .. }) {
- let _ = self.abort(&commit_messages).await;
- }
- return Err(error);
- }
- Ok(())
+ self.try_commit(
+ CommitEntriesPlan::Direct {
+ entries,
+ changelog_entries,
+ new_index_entries,
+ check_from_snapshot,
+ },
+ Some(expected_snapshot_id),
+ commit_identifier,
+ false,
+ )
+ .await
}
/// Overwrite partitions with new data.
@@ -882,11 +874,16 @@ impl TableCommit {
Ok(())
}
- /// Abort a prepared commit by deleting newly written data, changelog and
index files.
+ /// Delete new files for an explicitly abandoned, known-uncommitted write.
+ ///
+ /// Mirrors Java `FileStoreCommitImpl.abort`; removed inputs are preserved.
+ /// Call only when these messages will never be submitted. Never call after
+ /// a commit whose outcome is unknown: publication may have succeeded
before
+ /// its response failed. File deletion is best-effort.
///
- /// Deletion is best-effort and mirrors Python `FileStoreCommit.abort`:
missing
- /// files or storage errors are ignored so abort cleanup never masks the
- /// original write failure.
+ /// Format Tables follow Java two-phase cleanup instead: discard staging,
+ /// roll back unregistered append targets and preserve overwrite
replacements
+ /// or successfully registered append files.
pub async fn abort(&self, commit_messages: &[CommitMessage]) -> Result<()>
{
if self.table.is_format_table() {
return FormatTableCommit::new(&self.table)
@@ -3617,10 +3614,11 @@ mod tests {
include!("table_commit/parity_tests.rs");
include!("table_commit/recovery_tests.rs");
include!("table_commit/row_tracking_tests.rs");
+ include!("table_commit/abort_tests.rs");
}
#[tokio::test]
- async fn abort_still_cleans_up_for_a_query_auth_table() {
+ async fn abort_is_allowed_for_a_query_auth_table() {
let table = crate::table::query_auth_table();
let commit = crate::table::WriteBuilder::new(&table).new_commit();
commit.abort(&[]).await.unwrap();
@@ -4688,7 +4686,7 @@ mod tests {
let snapshot =
snap_manager.get_latest_snapshot().await.unwrap().unwrap();
assert_eq!(snapshot.id(), 1);
assert!(snapshot.index_manifest().is_none());
- assert!(!file_io.exists(&index_path).await.unwrap());
+ assert!(file_io.exists(&index_path).await.unwrap());
}
#[tokio::test]
diff --git a/crates/paimon/src/table/table_commit/abort_tests.rs
b/crates/paimon/src/table/table_commit/abort_tests.rs
new file mode 100644
index 00000000..054785b6
--- /dev/null
+++ b/crates/paimon/src/table/table_commit/abort_tests.rs
@@ -0,0 +1,121 @@
+// 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 explicit_abort_deletes_only_new_message_files() {
+ let io = test_file_io();
+ let path = "memory:/explicit-abort-new-message-files";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ let mut message = append_message("data.parquet");
+ message.new_files[0].extra_files = vec!["data.parquet.index".into()];
+ let mut blob = test_data_file("payload.blob", 10);
+ blob.external_path = Some(format!("{path}/external/payload.blob"));
+ message.new_files.push(blob);
+ message.new_changelog_files = vec![test_data_file("changelog.parquet",
10)];
+ message.compact_before = vec![test_data_file("old.parquet", 10)];
+ message.deleted_files = message.compact_before.clone();
+ message.compact_after = vec![test_data_file("compact.parquet", 10)];
+ message.compact_changelog_files =
vec![test_data_file("compact-changelog.parquet", 10)];
+ let mut hash = test_global_index_file("hash", 0, 0, 9);
+ hash.index_type = "HASH".into();
+ hash.global_index_meta = None;
+ message.new_index_files = vec![hash, test_deletion_vector_index_file("dv",
"data.parquet")];
+ message.compact_new_index_files = vec![test_global_index_file("global", 0,
0, 9)];
+ message.deleted_index_files = vec![test_global_index_file("old-index", 0,
0, 9)];
+ message.compact_deleted_index_files = message.deleted_index_files.clone();
+ let paths = [
+ "bucket-0/data.parquet", "bucket-0/data.parquet.index",
"external/payload.blob",
+ "bucket-0/changelog.parquet", "bucket-0/old.parquet",
"bucket-0/compact.parquet",
+ "bucket-0/compact-changelog.parquet", "index/hash", "index/dv",
"index/global", "index/old-index",
+ ].map(|name| format!("{path}/{name}"));
+ for file in &paths {
+
io.new_output(file).unwrap().write(bytes::Bytes::from_static(b"keep")).await.unwrap();
+ }
+ for _ in 0..2 {
+ commit.abort(std::slice::from_ref(&message)).await.unwrap();
+ for file in &paths {
+ let retained_input = file.ends_with("/old.parquet") ||
file.ends_with("/old-index");
+ assert_eq!(io.exists(file).await.unwrap(), retained_input,
"{file}");
+ }
+ }
+ assert!(latest_snapshot(&io, path).await.is_none());
+}
+
+#[tokio::test]
+async fn failed_commit_preserves_files_after_a_lost_response() {
+ for publish_first in [false, true] {
+ for identifier in [BATCH_COMMIT_IDENTIFIER, 42] {
+ let io = test_file_io();
+ let path =
format!("memory:/abort-response-loss-{publish_first}-{identifier}");
+ setup_dirs(&io, &path).await;
+ let mut commit = setup_commit(&io, &path);
+ 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,
+ });
+ let mut message = append_message("data.parquet");
+ message.new_files[0].extra_files =
vec!["data.parquet.index".into()];
+ message.new_changelog_files =
vec![test_data_file("changelog.parquet", 10)];
+ message.new_index_files = vec![test_global_index_file("global", 0,
0, 9)];
+ let paths = ["bucket-0/data.parquet",
"bucket-0/data.parquet.index", "bucket-0/changelog.parquet", "index/global"]
+ .map(|name| format!("{path}/{name}"));
+ for file in &paths {
+
io.new_output(file).unwrap().write(bytes::Bytes::from_static(b"prepared")).await.unwrap();
+ }
+ let error = commit.commit_with_identifier(vec![message.clone()],
identifier).await.unwrap_err();
+ assert!(error.to_string().contains("outcome may be unknown"),
"{error}");
+ let manifests = manifest_paths(&io, &path).await;
+ // Never explicitly abort messages with an unknown commit outcome.
+ for file in &paths {
+ assert!(io.exists(file).await.unwrap(), "{file}");
+ }
+ assert_eq!(manifest_paths(&io, &path).await, manifests);
+ assert_eq!(latest_snapshot(&io, &path).await.is_some(),
publish_first);
+ commit.filter_and_commit_with_identifier(vec![message],
identifier).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!(active_entries(&io, &path, &snapshot).await.len(), 1);
+ assert!(snapshot.changelog_manifest_list().is_some());
+ assert!(snapshot.index_manifest().is_some());
+ }
+ }
+}
+
+#[tokio::test]
+async fn guarded_retry_preserves_already_published_files() {
+ let io = test_file_io();
+ let path = "memory:/guarded-retry-preserves-files";
+ setup_dirs(&io, path).await;
+ let commit = setup_commit(&io, path);
+ commit.commit(vec![append_message("original")]).await.unwrap();
+ let index_path = format!("{path}/index/global");
+
io.new_output(&index_path).unwrap().write(bytes::Bytes::from_static(b"index")).await.unwrap();
+ let mut message = CommitMessage::new(vec![], 0, vec![]);
+ message.new_index_files = vec![test_global_index_file("global", 0, 0, 9)];
+ commit.commit_if_latest_snapshot(vec![message.clone()], 1).await.unwrap();
+ let snapshot = latest_snapshot(&io, path).await.unwrap();
+ let error = commit.commit_if_latest_snapshot(vec![message.clone()],
1).await.unwrap_err();
+ assert!(error.to_string().contains("Snapshot changed"), "{error}");
+ // A refused retry does not make already published files safe to discard.
+ assert!(io.exists(&index_path).await.unwrap());
+ assert_eq!(latest_snapshot(&io, path).await.unwrap().id(), snapshot.id());
+ assert!(snapshot.index_manifest().is_some());
+}
diff --git a/crates/paimon/src/table/table_update.rs
b/crates/paimon/src/table/table_update.rs
index 444c7064..e84c0053 100644
--- a/crates/paimon/src/table/table_update.rs
+++ b/crates/paimon/src/table/table_update.rs
@@ -125,7 +125,7 @@ impl TableUpdate {
/// Create a row-ID updater sharing one snapshot across per-call columns.
pub async fn new_update_by_row_id(&self) ->
crate::Result<TableUpdateByRowId> {
- TableUpdateByRowId::new(&self.table, self.commit_user.clone()).await
+ TableUpdateByRowId::new(&self.table).await
}
/// Update existing rows from chunks of one Arrow table containing
`_ROW_ID`.
@@ -172,11 +172,8 @@ impl TableUpdate {
.map_or_else(Vec::new, |writer|
writer.commit_messages().to_vec()))
}
.await;
- if result.is_err() {
- if let Some(writer) = &mut writer {
- let _ = writer.abort().await;
- }
- }
+ // Preserve earlier prepared groups on failure. Never delete files
+ // based on CommitMessage.
result
}
@@ -189,14 +186,8 @@ impl TableUpdate {
assignments: Vec<(String, super::UpdateAssignment)>,
read_columns: Vec<String>,
) -> crate::Result<Vec<CommitMessage>> {
- super::table_update_predicate::update(
- &self.table,
- &self.commit_user,
- predicate,
- assignments,
- read_columns,
- )
- .await
+ super::table_update_predicate::update(&self.table, predicate,
assignments, read_columns)
+ .await
}
/// Upsert complete Arrow rows by composite key through the core upsert
diff --git a/crates/paimon/src/table/table_update_by_row_id.rs
b/crates/paimon/src/table/table_update_by_row_id.rs
index 6d79e8de..c320ca60 100644
--- a/crates/paimon/src/table/table_update_by_row_id.rs
+++ b/crates/paimon/src/table/table_update_by_row_id.rs
@@ -18,7 +18,7 @@
//! Row-ID updates sharing one target snapshot and file index.
use super::data_evolution_writer::RowIdFileIndex;
-use crate::table::{CommitMessage, DataEvolutionWriter, Table, TableCommit};
+use crate::table::{CommitMessage, DataEvolutionWriter, Table};
use arrow_array::RecordBatch;
use std::collections::{HashMap, HashSet};
@@ -36,27 +36,21 @@ fn invalid(message: impl Into<String>) -> crate::Error {
/// of the same file group; updating a common column of that group is rejected.
pub struct TableUpdateByRowId {
table: Table,
- commit_user: String,
index: RowIdFileIndex,
updated: HashMap<i32, HashSet<i64>>,
messages: Vec<CommitMessage>,
}
impl TableUpdateByRowId {
- pub(crate) async fn new(table: &Table, commit_user: String) ->
crate::Result<Self> {
- Self::with_index(table, commit_user, RowIdFileIndex::load(table,
None).await?)
+ pub(crate) async fn new(table: &Table) -> crate::Result<Self> {
+ Self::with_index(table, RowIdFileIndex::load(table, None).await?)
}
- pub(super) fn with_index(
- table: &Table,
- commit_user: String,
- index: RowIdFileIndex,
- ) -> crate::Result<Self> {
+ pub(super) fn with_index(table: &Table, index: RowIdFileIndex) ->
crate::Result<Self> {
// Validate table layout without fixing any update columns.
let _ = DataEvolutionWriter::new(table, Vec::new())?;
Ok(Self {
table: table.clone(),
- commit_user,
index,
updated: HashMap::new(),
messages: Vec::new(),
@@ -120,13 +114,12 @@ impl TableUpdateByRowId {
&self.messages
}
- /// Abort staged, uncommitted files after a failed operation.
+ /// Reset update state while preserving prepared files.
pub async fn abort(&mut self) -> crate::Result<()> {
- let result = TableCommit::new(self.table.clone(),
self.commit_user.clone())
- .abort(&self.messages)
- .await;
+ // Never delete CommitMessage files. They may already be published even
+ // when the caller received an exception from commit.
self.messages.clear();
self.updated.clear();
- result
+ Ok(())
}
}
diff --git a/crates/paimon/src/table/table_update_predicate.rs
b/crates/paimon/src/table/table_update_predicate.rs
index f71b5aaa..8bf5ef8b 100644
--- a/crates/paimon/src/table/table_update_predicate.rs
+++ b/crates/paimon/src/table/table_update_predicate.rs
@@ -101,7 +101,6 @@ fn validate(
pub(super) async fn update(
table: &Table,
- commit_user: &str,
predicate: Option<Predicate>,
assignments: Vec<(String, UpdateAssignment)>,
read_columns: Vec<String>,
@@ -135,7 +134,7 @@ pub(super) async fn update(
let plan = read_builder.new_scan().with_scan_all_files().plan().await?;
let index = RowIdFileIndex::from_splits(scan_table.clone(),
plan.splits())?;
let groups = ordered_file_groups(plan.splits())?;
- let mut updater = TableUpdateByRowId::with_index(table,
commit_user.into(), index)?;
+ let mut updater = TableUpdateByRowId::with_index(table, index)?;
let reader = read_builder.new_read()?;
let schema =
crate::arrow::build_target_arrow_schema(table.schema().fields())?;
let columns: Vec<_> = assignments.iter().map(|(name, _)|
name.clone()).collect();
@@ -163,9 +162,8 @@ pub(super) async fn update(
Ok(updater.commit_messages().to_vec())
}
.await;
- if result.is_err() {
- let _ = updater.abort().await;
- }
+ // Preserve earlier prepared groups on failure. Never delete files based
+ // on CommitMessage when a callback or a later update fails.
result
}
diff --git a/crates/paimon/src/table/table_upsert.rs
b/crates/paimon/src/table/table_upsert.rs
index aa8ee45f..7860825a 100644
--- a/crates/paimon/src/table/table_upsert.rs
+++ b/crates/paimon/src/table/table_upsert.rs
@@ -260,15 +260,8 @@ impl TableUpsert {
Ok(())
}
.await;
- if result.is_err() && !messages.is_empty() {
- if let Ok(builder) = self
- .table
- .new_write_builder()
- .with_commit_user(self.commit_user)
- {
- let _ = builder.new_commit().abort(&messages).await;
- }
- }
+ // Preserve earlier prepared groups on failure. Never delete files
+ // based on CommitMessage; a failed response does not prove ownership.
result.map(|()| messages)
}
}
diff --git a/crates/paimon/src/table/table_write.rs
b/crates/paimon/src/table/table_write.rs
index b38fed10..25f8e1df 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -1130,9 +1130,7 @@ impl TableWrite {
}
}
if let Some(error) = error {
- self.failed = true;
- let commit = super::TableCommit::new(self.table.clone(),
self.commit_user.clone());
- let _ = commit.abort(&messages).await;
+ self.fail_prepare(&messages).await;
return Err(error);
}
@@ -1142,9 +1140,7 @@ impl TableWrite {
{
Ok(files) => files,
Err(error) => {
- self.failed = true;
- let commit = super::TableCommit::new(self.table.clone(),
self.commit_user.clone());
- let _ = commit.abort(&messages).await;
+ self.fail_prepare(&messages).await;
return Err(error);
}
};
@@ -1165,6 +1161,17 @@ impl TableWrite {
Ok(messages)
}
+ async fn fail_prepare(&mut self, messages: &[CommitMessage]) {
+ self.failed = true;
+ self.close().await;
+ // Only this failed invocation's outputs: no message has been returned
+ // or submitted. Earlier successful prepare calls belong to the caller.
+ let commit = super::TableCommit::new(self.table.clone(),
self.commit_user.clone());
+ if let Err(error) = commit.abort(messages).await {
+ log::warn!("Failed to discard unreturned prepared files: {error}");
+ }
+ }
+
async fn create_writer(&mut self, partition_bytes: Vec<u8>, bucket: i32)
-> Result<()> {
let partition_path = self.resolve_partition_path(&partition_bytes)?;
diff --git a/crates/paimon/src/table/vindex_index_build_builder.rs
b/crates/paimon/src/table/vindex_index_build_builder.rs
index 6b255713..e63f3658 100644
--- a/crates/paimon/src/table/vindex_index_build_builder.rs
+++ b/crates/paimon/src/table/vindex_index_build_builder.rs
@@ -182,6 +182,8 @@ impl<'a> VindexIndexBuildBuilder<'a> {
{
Ok(index_file) => index_file,
Err(error) => {
+ // Earlier outputs are private to this preparation; nothing
+ // has been returned to a caller or submitted for commit.
let _ = commit.abort(&messages).await;
return Err(error);
}
diff --git a/crates/paimon/tests/dynamic_bucket_write_test.rs
b/crates/paimon/tests/dynamic_bucket_write_test.rs
index 8e29704a..de409ddd 100644
--- a/crates/paimon/tests/dynamic_bucket_write_test.rs
+++ b/crates/paimon/tests/dynamic_bucket_write_test.rs
@@ -125,7 +125,7 @@ impl paimon::io::FileIOProvider for FailSecondHash {
async fn create(&self, path: &str) -> paimon::Result<(opendal::Operator,
String)> {
let path = path.strip_prefix("file://").unwrap_or(path);
let name = path.rsplit('/').next().unwrap();
- if name.starts_with("index-") {
+ if name.starts_with("index-") && path.contains("/index/") {
let mut indexes = self.indexes.lock().unwrap();
if !indexes.iter().any(|item| item == path) {
indexes.push(path.to_string());
@@ -200,3 +200,88 @@ async fn failed_hash_prepare_cleans_indexes_and_data() {
.to_string()
.contains("cannot be reused"));
}
+
+#[tokio::test]
+async fn failed_later_prepare_preserves_returned_and_committed_files() {
+ use paimon::catalog::Identifier;
+ use paimon::io::FileIOBuilder;
+ use paimon::table::Table;
+ use std::sync::Arc;
+
+ for publish_first in [false, true] {
+ let tmp = tempfile::tempdir().unwrap();
+ let mut config = opendal_service_fs::FsConfig::default();
+ config.root = Some("/".into());
+ let provider = Arc::new(FailSecondHash {
+ operator: opendal::Operator::from_config(config).unwrap(),
+ indexes: Default::default(),
+ data: Default::default(),
+ });
+ let io = FileIOBuilder::new("file")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let path = tmp.path().to_str().unwrap();
+ let table = Table::new(
+ io.clone(),
+ Identifier::new("default", "later_failure"),
+ path.into(),
+ pk_schema(&[("bucket", "-1"), ("dynamic-bucket.target-row-num",
"1")]),
+ None,
+ );
+ setup_dirs(&io, path).await;
+ let builder = table.new_write_builder();
+ let mut writer = builder.new_write().unwrap();
+ writer
+ .write_arrow_batch(&make_batch(vec![1], vec![10]))
+ .await
+ .unwrap();
+ let messages = writer.prepare_commit().await.unwrap();
+ let returned = provider
+ .data
+ .lock()
+ .unwrap()
+ .iter()
+ .chain(provider.indexes.lock().unwrap().iter())
+ .cloned()
+ .collect::<std::collections::HashSet<_>>();
+ assert!(!returned.is_empty());
+ if publish_first {
+ builder.new_commit().commit(messages.clone()).await.unwrap();
+ }
+ writer
+ .write_arrow_batch(&make_batch(vec![2], vec![20]))
+ .await
+ .unwrap();
+ assert!(writer
+ .prepare_commit()
+ .await
+ .unwrap_err()
+ .to_string()
+ .contains("injected HASH write failure"));
+ writer.close().await;
+ for file in provider
+ .data
+ .lock()
+ .unwrap()
+ .iter()
+ .chain(provider.indexes.lock().unwrap().iter())
+ {
+ assert_eq!(
+ std::path::Path::new(file).exists(),
+ returned.contains(file),
+ "{file}"
+ );
+ }
+ if !publish_first {
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+ let snapshot = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(snapshot.total_record_count(), Some(1));
+ }
+}
diff --git a/crates/paimon/tests/table_update_test.rs
b/crates/paimon/tests/table_update_test.rs
index ce85b467..3908a3dc 100644
--- a/crates/paimon/tests/table_update_test.rs
+++ b/crates/paimon/tests/table_update_test.rs
@@ -654,7 +654,7 @@ async fn
grouped_updates_allow_disjoint_columns_on_the_same_file() {
}
#[tokio::test]
-async fn overlapping_groups_abort_previously_prepared_columns() {
+async fn overlapping_groups_preserve_previously_prepared_columns() {
let table = evolution_table().await;
seed(&table).await;
let before = parquet_files(&table).await;
@@ -681,7 +681,9 @@ async fn
overlapping_groups_abort_previously_prepared_columns() {
.contains("overlapping first_row_ids by column"),
"{error}"
);
- assert_eq!(parquet_files(&table).await, before);
+ let after = parquet_files(&table).await;
+ assert!(after.len() > before.len());
+ assert!(before.iter().all(|path| after.contains(path)));
assert_eq!(
read_rows(&table).await,
vec![vec![1, 10, 100], vec![2, 20, 200], vec![3, 30, 300]]
@@ -950,7 +952,61 @@ async fn
row_id_factory_shares_snapshot_and_selects_columns_per_call() {
}
#[tokio::test]
-async fn grouped_input_failure_aborts_files_and_preserves_the_cause() {
+async fn row_id_abort_preserves_prepared_and_published_updates() {
+ for published in [false, true] {
+ let table = evolution_table().await;
+ seed(&table).await;
+ let builder = table.new_write_builder();
+ let update = builder.new_update().unwrap();
+ let mut updater = update.new_update_by_row_id().await.unwrap();
+ let messages = updater
+ .update_columns(
+ vec![matched(vec![0], &[("value", vec![11])])],
+ vec!["value".into()],
+ )
+ .await
+ .unwrap();
+ let prepared_files = parquet_files(&table).await;
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id();
+ if published {
+ builder.new_commit().commit(messages.clone()).await.unwrap();
+ }
+ updater.abort().await.unwrap();
+ updater.abort().await.unwrap();
+ assert!(updater.commit_messages().is_empty());
+ assert_eq!(parquet_files(&table).await, prepared_files);
+ if !published {
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ snapshot_id
+ );
+ assert_eq!(
+ read_rows(&table).await,
+ vec![vec![1, 10, 100], vec![2, 20, 200], vec![3, 30, 300]]
+ );
+ builder.new_commit().commit(messages).await.unwrap();
+ }
+ assert_eq!(
+ read_rows(&table).await,
+ vec![vec![1, 11, 100], vec![2, 20, 200], vec![3, 30, 300]]
+ );
+ }
+}
+
+#[tokio::test]
+async fn grouped_input_failure_preserves_files_and_the_cause() {
let table = evolution_table().await;
seed(&table).await;
let before = parquet_files(&table).await;
@@ -966,7 +1022,9 @@ async fn
grouped_input_failure_aborts_files_and_preserves_the_cause() {
.await
.unwrap_err();
assert!(error.to_string().contains("input generator failed"));
- assert_eq!(parquet_files(&table).await, before);
+ let after = parquet_files(&table).await;
+ assert!(after.len() > before.len());
+ assert!(before.iter().all(|path| after.contains(path)));
assert_eq!(
read_rows(&table).await,
vec![vec![1, 10, 100], vec![2, 20, 200], vec![3, 30, 300]]
@@ -1146,7 +1204,7 @@ async fn predicate_array_assignments_span_file_groups() {
}
#[tokio::test]
-async fn predicate_callback_failure_aborts_earlier_group_files() {
+async fn predicate_callback_failure_preserves_earlier_group_files() {
use paimon::table::UpdateAssignment;
use std::sync::atomic::{AtomicUsize, Ordering};
let table = evolution_table().await;
@@ -1182,7 +1240,9 @@ async fn
predicate_callback_failure_aborts_earlier_group_files() {
.unwrap_err();
assert!(error.to_string().contains("second callback failed"));
assert_eq!(calls.load(Ordering::SeqCst), 2);
- assert_eq!(parquet_files(&table).await, before);
+ let after = parquet_files(&table).await;
+ assert!(after.len() > before.len());
+ assert!(before.iter().all(|path| after.contains(path)));
}
#[tokio::test]
@@ -1461,7 +1521,6 @@ async fn
row_id_nested_overlap_uses_leaf_identity_across_calls() {
.unwrap();
let child = matched(vec![1], &[("profile.a", vec![22])]);
let update = table.new_write_builder().new_update().unwrap();
- let original_files = parquet_files(&table).await;
for reverse in [false, true] {
let mut updater = update.new_update_by_row_id().await.unwrap();
let calls = if reverse {
@@ -1483,7 +1542,7 @@ async fn
row_id_nested_overlap_uses_leaf_identity_across_calls() {
assert_eq!(updater.commit_messages().len(), messages);
assert_eq!(parquet_files(&table).await, staged_files);
updater.abort().await.unwrap();
- assert_eq!(parquet_files(&table).await, original_files);
+ assert_eq!(parquet_files(&table).await, staged_files);
}
// Disjoint sibling leaves of the same file group remain valid.
let mut updater = update.new_update_by_row_id().await.unwrap();
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 85e6487a..8252cbd8 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -186,9 +186,19 @@ When supported indexes are configured, each data file gets
its own index.
The complete serialized index is embedded in the manifest when its size
is at most `file-index.in-manifest-threshold` (default `500 B`); larger indexes
are stored beside the data file as a `.index` sidecar. Indexed write failures
-return an error and clean up newly created files on a best-effort basis.
-Use commit `abort` to clean up files after a successful `prepare_commit`
-when the prepared write will not be committed.
+return an error. Failed preparation discards outputs from that invocation
before
+returning any messages. Files handed off by an earlier successful preparation
+remain available, including after a later preparation or Paimon Table commit
+exception. Explicit commit `abort` deletes newly written files only when the
caller
+knows those messages are uncommitted and will never be submitted. Never call it
+when the Paimon Table commit outcome is unknown: publication can succeed even
when
+its response fails.
+
+Format Tables follow Java's two-phase publication: validation failures discard
+staging only; append publication or partition-registration failures roll back
new
+targets; overwrite failures retain replacement targets after old data is
removed.
+Successfully registered append targets also survive a later abort. Cleanup
attempts
+every file, and a cleanup failure does not replace the original commit error.
`file-index.read.enabled` controls only reading, independently of index
creation.
Existing files are not backfilled. Index generation is not supported for