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 fb5e8f7e fix: prevent concurrent snapshot overwrites (#1231)
fb5e8f7e is described below
commit fb5e8f7efeb592fab916b8ee8873405a947e2f17
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Oct 27 16:57:07 2026 +0800
fix: prevent concurrent snapshot overwrites (#1231)
---
crates/integrations/datafusion/src/procedures.rs | 140 +++++++++---
crates/paimon/src/io/file_io.rs | 212 +++++++++++++++++
crates/paimon/src/io/file_io/fuse.rs | 19 ++
crates/paimon/src/table/branch_write_tests.rs | 11 +-
crates/paimon/src/table/snapshot_commit.rs | 4 +-
crates/paimon/src/table/snapshot_manager.rs | 252 ++++++++++++++++++---
.../src/table/table_commit/recovery_tests.rs | 113 +++++++++
crates/paimon/tests/dynamic_bucket_write_test.rs | 18 +-
docs/src/getting-started.md | 18 ++
9 files changed, 713 insertions(+), 74 deletions(-)
diff --git a/crates/integrations/datafusion/src/procedures.rs
b/crates/integrations/datafusion/src/procedures.rs
index 6b1d0b28..99439c35 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -823,6 +823,30 @@ async fn clean_larger_than(
Ok(())
}
+async fn rollback_to_snapshot(
+ sm: &SnapshotManager,
+ tm: &TagManager,
+ snapshot: &Snapshot,
+) -> DFResult<()> {
+ let id = snapshot.id();
+ // Restore expired tagged snapshots before removing any newer history. A
+ // backend may reject publication, in which case rollback must not delete
it.
+ if !sm
+ .file_io()
+ .exists(&sm.snapshot_path(id))
+ .await
+ .map_err(to_datafusion_error)?
+ {
+ sm.commit_snapshot(snapshot)
+ .await
+ .map_err(to_datafusion_error)?;
+ sm.write_earliest_hint(id)
+ .await
+ .map_err(to_datafusion_error)?;
+ }
+ clean_larger_than(sm, tm, id).await
+}
+
async fn proc_rollback_to(
ctx: &SessionContext,
catalog: &Arc<dyn Catalog>,
@@ -869,41 +893,14 @@ async fn proc_rollback_to(
.parse()
.map_err(|_| DataFusionError::Plan(format!("Invalid
snapshot_id: '{id_str}'")))?;
let snapshot = resolve_snapshot_by_id(&sm, &tm, id).await?;
- clean_larger_than(&sm, &tm, id).await?;
- if !sm
- .file_io()
- .exists(&sm.snapshot_path(id))
- .await
- .map_err(to_datafusion_error)?
- {
- sm.commit_snapshot(&snapshot)
- .await
- .map_err(to_datafusion_error)?;
- sm.write_earliest_hint(id)
- .await
- .map_err(to_datafusion_error)?;
- }
+ rollback_to_snapshot(&sm, &tm, &snapshot).await?;
} else if let Some(tag_name) = args.get("tag") {
let snapshot = tm
.get(tag_name)
.await
.map_err(to_datafusion_error)?
.ok_or_else(|| DataFusionError::Plan(format!("Tag '{tag_name}'
does not exist")))?;
- let snapshot_id = snapshot.id();
- clean_larger_than(&sm, &tm, snapshot_id).await?;
- if !sm
- .file_io()
- .exists(&sm.snapshot_path(snapshot_id))
- .await
- .map_err(to_datafusion_error)?
- {
- sm.commit_snapshot(&snapshot)
- .await
- .map_err(to_datafusion_error)?;
- sm.write_earliest_hint(snapshot_id)
- .await
- .map_err(to_datafusion_error)?;
- }
+ rollback_to_snapshot(&sm, &tm, &snapshot).await?;
} else {
return Err(DataFusionError::Plan(
"rollback_to requires either 'snapshot_id' or 'tag'
argument".to_string(),
@@ -1955,6 +1952,91 @@ mod tests {
assert!(result.is_err());
}
+ #[derive(Debug)]
+ struct RejectSnapshotPublication(opendal::Operator);
+
+ #[async_trait::async_trait]
+ impl paimon::io::FileIOProvider for RejectSnapshotPublication {
+ async fn create(&self, path: &str) ->
paimon::Result<(opendal::Operator, String)> {
+ Ok((
+ self.0.clone(),
+ path.strip_prefix("memory:/").unwrap().into(),
+ ))
+ }
+
+ async fn write_atomic_if_not_exists(
+ &self,
+ _path: &str,
+ _bytes: bytes::Bytes,
+ ) -> paimon::Result<Option<bool>> {
+ Err(paimon::Error::IoUnsupported {
+ message: "snapshot publication unavailable".into(),
+ })
+ }
+ }
+
+ #[tokio::test]
+ async fn test_rollback_publication_failure_preserves_history() {
+ let op =
opendal::Operator::new(opendal::services::Memory::default()).unwrap();
+ let file_io = FileIOBuilder::new("memory")
+ .with_provider(Arc::new(RejectSnapshotPublication(op)))
+ .build()
+ .unwrap();
+ let path = "memory:/rollback_failure";
+ let sm = SnapshotManager::new(file_io.clone(), path.into());
+ let tm = TagManager::new(file_io.clone(), path.into());
+ // Snapshot 1 has expired, but a tag still retains it. Seed the newer
+ // history through ordinary writes on a backend rejecting publication.
+ for id in [2, 3] {
+ file_io
+ .new_output(&sm.snapshot_path(id))
+ .unwrap()
+ .write(
+ serde_json::to_vec(&test_snapshot(id, id as u64 * 1000))
+ .unwrap()
+ .into(),
+ )
+ .await
+ .unwrap();
+ }
+ sm.write_latest_hint(3).await.unwrap();
+ tm.create("v1", &test_snapshot(1, 1000)).await.unwrap();
+ tm.create("v3", &test_snapshot(3, 3000)).await.unwrap();
+ let target = resolve_snapshot_by_id(&sm, &tm, 1).await.unwrap();
+
+ let error = rollback_to_snapshot(&sm, &tm, &target).await.unwrap_err();
+ assert!(error
+ .to_string()
+ .contains("snapshot publication unavailable"));
+ assert_eq!(sm.list_all_ids().await.unwrap(), vec![2, 3]);
+ assert_eq!(sm.get_latest_snapshot_id().await.unwrap(), Some(3));
+ assert!(tm.tag_exists("v1").await.unwrap());
+ assert!(tm.tag_exists("v3").await.unwrap());
+ assert!(!file_io.exists(&sm.snapshot_path(1)).await.unwrap());
+ }
+
+ #[tokio::test]
+ async fn test_rollback_restores_expired_snapshot_before_cleaning_history()
{
+ let (_, sm, tm) = setup("memory:/rollback_expired").await;
+ for id in [2, 3] {
+ sm.commit_snapshot(&test_snapshot(id, id as u64 * 1000))
+ .await
+ .unwrap();
+ }
+ let target = test_snapshot(1, 1000);
+ tm.create("v1", &target).await.unwrap();
+ tm.create("v3", &test_snapshot(3, 3000)).await.unwrap();
+
+ rollback_to_snapshot(&sm, &tm, &target).await.unwrap();
+
+ assert_eq!(sm.get_snapshot(1).await.unwrap(), target);
+ assert_eq!(sm.list_all_ids().await.unwrap(), vec![1]);
+ assert_eq!(sm.get_latest_snapshot_id().await.unwrap(), Some(1));
+ assert_eq!(sm.earliest_snapshot_id().await.unwrap(), Some(1));
+ assert!(tm.tag_exists("v1").await.unwrap());
+ assert!(!tm.tag_exists("v3").await.unwrap());
+ }
+
#[tokio::test]
async fn test_later_or_equal_exact_live() {
let (_, sm, tm) = setup("memory:/test_later_exact").await;
diff --git a/crates/paimon/src/io/file_io.rs b/crates/paimon/src/io/file_io.rs
index acf9a2f4..79779c3a 100644
--- a/crates/paimon/src/io/file_io.rs
+++ b/crates/paimon/src/io/file_io.rs
@@ -87,6 +87,20 @@ pub trait FileIOProvider: std::fmt::Debug + Send + Sync +
'static {
/// Rename requires both paths to resolve to the same shared service
instance.
async fn create(&self, path: &str) -> crate::Result<(Operator, String)>;
+ /// Optionally publish a complete immutable file without replacing an
existing file.
+ ///
+ /// Return `Some(true)` on success or `Some(false)` for a definite
conflict.
+ /// Return `None` only if no publication was attempted; FileIO then uses
the
+ /// resolved operator's safe publication primitives. An error may have an
+ /// unknown outcome and is propagated without falling back to another
write.
+ async fn write_atomic_if_not_exists(
+ &self,
+ _path: &str,
+ _bytes: Bytes,
+ ) -> crate::Result<Option<bool>> {
+ Ok(None)
+ }
+
/// Return a stable namespace for the resolved storage target. `None`
/// disables caching; distinct endpoints must use distinct namespaces.
async fn create_with_cache_namespace(
@@ -923,6 +937,137 @@ impl FileIO {
Ok(())
}
+ /// Publish a complete immutable file only if the target does not exist.
+ /// Returns `false` only for a definite publication conflict. Other
failures
+ /// may have an unknown outcome and must never trigger an unconditional
write.
+ pub(crate) async fn write_atomic_if_not_exists(
+ &self,
+ path: &str,
+ bytes: Bytes,
+ ) -> Result<bool> {
+ if let FileIOBackend::Provider(provider) = &self.backend {
+ if let Some(published) = provider
+ .write_atomic_if_not_exists(path, bytes.clone())
+ .await?
+ {
+ return Ok(published);
+ }
+ }
+ let source = self.file_source(path)?;
+ let (op, relative_path, _) = source.resolve(path).await?;
+
+ #[cfg(feature = "storage-fs")]
+ if matches!(source, FileSource::Static { local_fs: true, .. }) {
+ return Self::write_local_atomic_if_not_exists(path, &op,
&relative_path, bytes).await;
+ }
+
+ let info = op.info();
+ let capability = info.capability();
+ let result = if capability.rename_with_if_not_exists {
+ let staging_path = format!("{relative_path}.tmp-{}",
uuid::Uuid::new_v4());
+ op.write(&staging_path, bytes).await?;
+ let result = op
+ .rename_with(&staging_path, &relative_path)
+ .if_not_exists(true)
+ .await;
+ if result.as_ref().is_err_and(|error| {
+ matches!(
+ error.kind(),
+ opendal::ErrorKind::AlreadyExists |
opendal::ErrorKind::ConditionNotMatch
+ )
+ }) {
+ let _ = op.delete(&staging_path).await;
+ }
+ result
+ } else if capability.write_with_if_not_exists
+ // Only known whole-object publishers are safe here: position
+ // writers can expose incomplete files, and COS ignores conditional
+ // puts on buckets that have ever enabled versioning. OSS requires
+ // the bucket's default, never-enabled versioning configuration.
+ && matches!(info.scheme(), "memory" | "s3" | "oss" | "gcs")
+ {
+ op.write_with(&relative_path, bytes)
+ .if_not_exists(true)
+ .await
+ .map(|_| ())
+ } else {
+ return Err(Error::IoUnsupported {
+ message: format!(
+ "Storage '{}' cannot atomically publish a complete
snapshot without overwriting '{path}'; use a catalog SnapshotCommit with
concurrency control",
+ info.scheme()
+ ),
+ });
+ };
+ match result {
+ Ok(()) => Ok(true),
+ Err(error)
+ if matches!(
+ error.kind(),
+ opendal::ErrorKind::AlreadyExists |
opendal::ErrorKind::ConditionNotMatch
+ ) =>
+ {
+ Ok(false)
+ }
+ Err(error) => Err(Error::from_opendal_with_context(
+ error,
+ format!("Failed to publish '{path}'"),
+ )),
+ }
+ }
+
+ #[cfg(feature = "storage-fs")]
+ async fn write_local_atomic_if_not_exists(
+ path: &str,
+ op: &Operator,
+ relative_path: &str,
+ bytes: Bytes,
+ ) -> Result<bool> {
+ // OpenDAL's filesystem conditional write exposes the target before
+ // its contents are complete. Stage locally, then publish without
+ // replacing another process's snapshot.
+ let relative = normalize_path(relative_path);
+ if std::path::Path::new(&relative)
+ .components()
+ .any(|component| matches!(component,
std::path::Component::ParentDir))
+ {
+ return Err(Error::IoUnsupported {
+ message: format!("Cannot publish a snapshot through a parent
path: '{path}'"),
+ });
+ }
+ let target = std::path::Path::new(&op.info().root()).join(relative);
+ tokio::task::spawn_blocking(move || -> opendal::Result<bool> {
+ use std::io::Write;
+
+ let parent = target.parent().ok_or_else(|| {
+ opendal::Error::new(opendal::ErrorKind::IsADirectory, "target
has no parent")
+ })?;
+ std::fs::create_dir_all(parent).map_err(new_std_io_error)?;
+ let mut builder = tempfile::Builder::new();
+ builder.prefix(".snapshot-");
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ builder.permissions(std::fs::Permissions::from_mode(0o666));
+ }
+ let mut staged =
builder.tempfile_in(parent).map_err(new_std_io_error)?;
+ staged.write_all(&bytes).map_err(new_std_io_error)?;
+ staged.as_file().sync_all().map_err(new_std_io_error)?;
+ match staged.persist_noclobber(target) {
+ Ok(_) => Ok(true),
+ Err(error) if error.error.kind() ==
std::io::ErrorKind::AlreadyExists => Ok(false),
+ Err(error) => Err(new_std_io_error(error.error)),
+ }
+ })
+ .await
+ .map_err(|error| Error::UnexpectedError {
+ message: format!("Failed to publish '{path}'"),
+ source: Some(Box::new(error)),
+ })?
+ .map_err(|error| {
+ Error::from_opendal_with_context(error, format!("Failed to publish
'{path}'"))
+ })
+ }
+
/// Copy a large file without materializing its entire contents in memory.
/// Format Table publication uses this when a backend does not support
/// rename, as is common for object stores and the in-memory test backend.
@@ -1158,6 +1303,9 @@ impl FileIOBuilder {
/// form, so the operator's root decides what they resolve against.
Scheme'd paths
/// (`s3://…`, `oss://…`) are rejected rather than misresolved: an
object-store operator
/// needs bucket/scheme resolution this hook deliberately does not provide.
+ /// Snapshot publication requires atomic no-replace publication from the
operator.
+ /// Filesystem conditional writes alone expose incomplete files; use the
built-in
+ /// `file` backend or a catalog SnapshotCommit when conditional rename is
unavailable.
pub fn with_fs_operator(mut self, operator: Operator) -> Self {
self.operator = Some(operator);
self
@@ -4185,4 +4333,68 @@ mod input_output_test {
Bytes::from_static(b"new metadata")
);
}
+
+ #[cfg(feature = "storage-oss")]
+ #[tokio::test]
+ async fn test_atomic_publication_oss_has_one_winner() {
+ use axum::body::Body;
+ use axum::http::{HeaderMap, Response, StatusCode, Uri};
+ use axum::Router;
+ use opendal::Configurator;
+
+ let objects = Arc::new(Mutex::new(HashMap::<String, Bytes>::new()));
+ let stored = objects.clone();
+ // An OSS bucket with default versioning atomically enforces this
header.
+ let app = Router::new().fallback(move |uri: Uri, headers: HeaderMap,
body: Bytes| {
+ let stored = stored.clone();
+ async move {
+ let mut objects = stored.lock().unwrap();
+ if headers.get("x-oss-forbid-overwrite").is_some_and(|value|
value == "true")
+ && objects.contains_key(uri.path())
+ {
+ return Response::builder()
+ .status(StatusCode::CONFLICT)
+
.body(Body::from("<Error><Code>FileAlreadyExists</Code><Message>exists</Message></Error>"))
+ .unwrap();
+ }
+ objects.insert(uri.path().into(), body);
+ Response::builder()
+ .header("etag", "\"mock-etag\"")
+ .body(Body::empty())
+ .unwrap()
+ }
+ });
+ let listener =
tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let endpoint = format!("http://{}", listener.local_addr().unwrap());
+ let server = tokio::spawn(async move { axum::serve(listener,
app).await.unwrap() });
+ let mut config = opendal_service_oss::OssConfig::default();
+ config.endpoint = Some(endpoint);
+ config.addressing_style = Some("path".into());
+ config.skip_signature = true;
+ let op = crate::io::with_http_transport(
+ Operator::new(config.into_builder().bucket("bucket")).unwrap(),
+ "paimon-atomic-publication-test",
+ );
+ let io = FileIOBuilder::new("file")
+ .with_fs_operator(op)
+ .build()
+ .unwrap();
+ let path = "/table/snapshot/snapshot-1";
+ let first = Bytes::from_static(b"first snapshot");
+ let second = Bytes::from_static(b"second snapshot");
+ let (a, b) = tokio::join!(
+ io.write_atomic_if_not_exists(path, first.clone()),
+ io.write_atomic_if_not_exists(path, second.clone())
+ );
+ server.abort();
+ let (a, b) = (a.unwrap(), b.unwrap());
+ assert_ne!(a, b, "exactly one conditional OSS PUT must win");
+ assert_eq!(
+ objects
+ .lock()
+ .unwrap()
+ .get("/bucket/table/snapshot/snapshot-1"),
+ Some(if a { &first } else { &second })
+ );
+ }
}
diff --git a/crates/paimon/src/io/file_io/fuse.rs
b/crates/paimon/src/io/file_io/fuse.rs
index d0a287db..27c03963 100644
--- a/crates/paimon/src/io/file_io/fuse.rs
+++ b/crates/paimon/src/io/file_io/fuse.rs
@@ -35,6 +35,25 @@ impl FileIOProvider for FuseFileIO {
self.fallback.create(path).await
}
+ async fn write_atomic_if_not_exists(
+ &self,
+ path: &str,
+ bytes: Bytes,
+ ) -> crate::Result<Option<bool>> {
+ if let Some(relative) = self.relative_path(path) {
+ // Require the mount to implement no-replace publication. An
+ // unsupported hard link must never fall back to copy or rename.
+ FileIO::write_local_atomic_if_not_exists(path, &self.mounted,
relative, bytes)
+ .await
+ .map(Some)
+ } else {
+ self.fallback
+ .write_atomic_if_not_exists(path, bytes)
+ .await
+ .map(Some)
+ }
+ }
+
async fn create_with_cache_namespace(
&self,
path: &str,
diff --git a/crates/paimon/src/table/branch_write_tests.rs
b/crates/paimon/src/table/branch_write_tests.rs
index 115a3557..2ce70d95 100644
--- a/crates/paimon/src/table/branch_write_tests.rs
+++ b/crates/paimon/src/table/branch_write_tests.rs
@@ -24,14 +24,7 @@ use std::sync::Arc;
async fn table(primary_key: bool, options: &[(&str, &str)]) ->
(tempfile::TempDir, Table) {
let dir = tempfile::tempdir().unwrap();
- // Scope the real filesystem operator to this temporary directory.
OpenDAL's
- // default root "/" cannot list an absolute path on another Windows drive.
- let mut config = opendal_service_fs::FsConfig::default();
- config.root = Some(dir.path().to_str().unwrap().to_string());
- let file_io = FileIOBuilder::new("file")
- .with_fs_operator(opendal::Operator::from_config(config).unwrap())
- .build()
- .unwrap();
+ let file_io = FileIOBuilder::new("file").build().unwrap();
let mut builder = Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column("value", DataType::Int(IntType::new()));
@@ -44,7 +37,7 @@ async fn table(primary_key: bool, options: &[(&str, &str)])
-> (tempfile::TempDi
let table = Table::new(
file_io.clone(),
Identifier::new("db", "t"),
- "file:/db.db/t".into(),
+ dir.path().join("db.db/t").to_str().unwrap().into(),
TableSchema::new(0, &builder.build().unwrap()),
None,
);
diff --git a/crates/paimon/src/table/snapshot_commit.rs
b/crates/paimon/src/table/snapshot_commit.rs
index 7f646090..7d49a62b 100644
--- a/crates/paimon/src/table/snapshot_commit.rs
+++ b/crates/paimon/src/table/snapshot_commit.rs
@@ -30,7 +30,7 @@ use std::sync::Arc;
/// Interface to commit a snapshot atomically.
///
/// Two implementations:
-/// - `RenamingSnapshotCommit` — file system atomic rename
+/// - `RenamingSnapshotCommit` — atomic no-replace file publication
/// - `RESTSnapshotCommit` — via Catalog API (e.g. REST)
#[async_trait]
pub trait SnapshotCommit: Send + Sync {
@@ -46,7 +46,7 @@ pub trait SnapshotCommit: Send + Sync {
) -> Result<bool>;
}
-/// A SnapshotCommit using file renaming to commit.
+/// A SnapshotCommit using atomic file publication without replacing an
existing snapshot.
///
/// Reference: [Java
RenamingSnapshotCommit](https://github.com/apache/paimon/blob/master/paimon-core/src/main/java/org/apache/paimon/catalog/RenamingSnapshotCommit.java)
pub struct RenamingSnapshotCommit {
diff --git a/crates/paimon/src/table/snapshot_manager.rs
b/crates/paimon/src/table/snapshot_manager.rs
index 1ca5344d..0c536874 100644
--- a/crates/paimon/src/table/snapshot_manager.rs
+++ b/crates/paimon/src/table/snapshot_manager.rs
@@ -268,10 +268,9 @@ impl SnapshotManager {
/// Writes the snapshot JSON to the target path. Returns `false` if the
/// target already exists (another writer won the race).
///
- /// On file systems that support atomic rename, we write to a temp file
- /// first then rename. On backends where rename is not supported (e.g.
- /// memory, object stores), we fall back to a direct write after an
- /// existence check.
+ /// Uses atomic no-replace publication: a staged file on the local
filesystem,
+ /// conditional rename, or a conditional object write. Backends without a
+ /// safe primitive must use a catalog-provided SnapshotCommit instead.
pub async fn commit_snapshot(&self, snapshot: &Snapshot) ->
crate::Result<bool> {
let target_path = self.snapshot_path(snapshot.id());
@@ -280,37 +279,14 @@ impl SnapshotManager {
source: Some(Box::new(e)),
})?;
- // Try rename-based atomic commit first, fall back to check-and-write.
- //
- // TODO: opendal's rename uses POSIX semantics which silently
overwrites the target.
- // The exists() check below narrows the race window but does not
eliminate it.
- // Java Paimon uses `lock.runWithLock(() -> !fileIO.exists(newPath)
&& callable.call())`
- // for full mutual exclusion. We need an external lock mechanism
(like Java's Lock
- // interface) for backends without atomic rename-no-replace support.
- let tmp_path = format!("{}.tmp-{}", target_path, uuid::Uuid::new_v4());
- let output = self.file_io.new_output(&tmp_path)?;
- output.write(bytes::Bytes::from(json.clone())).await?;
-
- // Check before rename to avoid silent overwrite (opendal uses POSIX
rename semantics)
- if self.file_io.exists(&target_path).await? {
- let _ = self.file_io.delete_file(&tmp_path).await;
+ if !self
+ .file_io
+ .write_atomic_if_not_exists(&target_path, bytes::Bytes::from(json))
+ .await?
+ {
return Ok(false);
}
- match self.file_io.rename(&tmp_path, &target_path).await {
- Ok(()) => {}
- Err(_) => {
- // Rename not supported (e.g. memory/object store).
- // Clean up temp file, then check-and-write.
- let _ = self.file_io.delete_file(&tmp_path).await;
- if self.file_io.exists(&target_path).await? {
- return Ok(false);
- }
- let output = self.file_io.new_output(&target_path)?;
- output.write(bytes::Bytes::from(json)).await?;
- }
- }
-
// Update LATEST hint (best-effort)
let _ = self.write_latest_hint(snapshot.id()).await;
Ok(true)
@@ -829,6 +805,218 @@ mod tests {
assert!(!result);
}
+ #[derive(Debug)]
+ struct SnapshotPublicationProvider {
+ op: opendal::Operator,
+ barrier: tokio::sync::Barrier,
+ synchronize: std::sync::atomic::AtomicBool,
+ reject: bool,
+ }
+
+ #[async_trait::async_trait]
+ impl crate::io::FileIOProvider for SnapshotPublicationProvider {
+ async fn create(&self, path: &str) ->
crate::Result<(opendal::Operator, String)> {
+ let relative = path.strip_prefix("memory:/").unwrap();
+ // Synchronize every resolution of the publication path. In the old
+ // check-then-write implementation both writers pass each check
+ // before either reaches its unconditional write.
+ if relative.ends_with("/snapshot-1")
+ && self.synchronize.load(std::sync::atomic::Ordering::SeqCst)
+ {
+ self.barrier.wait().await;
+ }
+ Ok((self.op.clone(), relative.to_string()))
+ }
+
+ async fn write_atomic_if_not_exists(
+ &self,
+ _: &str,
+ _: bytes::Bytes,
+ ) -> crate::Result<Option<bool>> {
+ if self.reject {
+ return Err(crate::Error::IoUnsupported {
+ message: "provider rejected snapshot publication".into(),
+ });
+ }
+ Ok(None)
+ }
+ }
+
+ #[tokio::test]
+ async fn test_commit_snapshot_concurrent_publication_has_one_winner() {
+ use std::sync::atomic::{AtomicBool, Ordering};
+ use std::sync::Arc;
+
+ let provider = Arc::new(SnapshotPublicationProvider {
+ op:
opendal::Operator::from_config(opendal::services::MemoryConfig::default()).unwrap(),
+ barrier: tokio::sync::Barrier::new(2),
+ synchronize: AtomicBool::new(true),
+ reject: false,
+ });
+ let io = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let manager = SnapshotManager::new(io,
"memory:/racing-snapshot".into());
+ let first = test_snapshot(1);
+ let mut json = serde_json::to_value(&first).unwrap();
+ json["commitUser"] = "other-writer".into();
+ let second: Snapshot = serde_json::from_value(json).unwrap();
+ let (a, b) = tokio::time::timeout(std::time::Duration::from_secs(5),
async {
+ tokio::join!(
+ manager.commit_snapshot(&first),
+ manager.commit_snapshot(&second)
+ )
+ })
+ .await
+ .expect("publication must not deadlock");
+ provider.synchronize.store(false, Ordering::SeqCst);
+ let (a, b) = (a.unwrap(), b.unwrap());
+ assert_ne!(a, b, "exactly one writer must win the same snapshot ID");
+ assert_eq!(
+ manager.get_snapshot(1).await.unwrap(),
+ if a { first } else { second }
+ );
+ assert_eq!(manager.get_latest_snapshot_id().await.unwrap(), Some(1));
+ let files = manager
+ .file_io
+ .list_status(&manager.snapshot_dir())
+ .await
+ .unwrap();
+ assert!(files.iter().all(|file| !file.path.contains(".tmp-")));
+ }
+
+ #[tokio::test]
+ async fn
test_commit_snapshot_provider_error_never_falls_back_to_operator() {
+ let provider = std::sync::Arc::new(SnapshotPublicationProvider {
+ op:
opendal::Operator::from_config(opendal::services::MemoryConfig::default()).unwrap(),
+ barrier: tokio::sync::Barrier::new(2),
+ synchronize: std::sync::atomic::AtomicBool::new(false),
+ reject: true,
+ });
+ let io = FileIOBuilder::new("memory")
+ .with_provider(provider.clone())
+ .build()
+ .unwrap();
+ let manager = SnapshotManager::new(io,
"memory:/provider-error".into());
+ let error = manager
+ .commit_snapshot(&test_snapshot(1))
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(error, crate::Error::IoUnsupported { ref message } if
message == "provider rejected snapshot publication")
+ );
+ assert!(provider.op.list("/").await.unwrap().is_empty());
+ }
+
+ #[cfg(feature = "storage-fs")]
+ #[tokio::test]
+ async fn test_commit_snapshot_local_concurrent_independent_writers() {
+ let dir = tempfile::tempdir().unwrap();
+ let table_path = dir.path().to_str().unwrap().to_string();
+ let barrier = std::sync::Arc::new(tokio::sync::Barrier::new(16));
+ let results = futures::future::join_all((0..16).map(|writer| {
+ let table_path = table_path.clone();
+ let barrier = barrier.clone();
+ async move {
+ // Separate FileIO instances also cover writers in other
processes:
+ // publication must not rely on a mutex owned by a committer.
+ let io = FileIOBuilder::new("file").build().unwrap();
+ let manager = SnapshotManager::new(io, table_path);
+ let mut json = serde_json::to_value(test_snapshot(1)).unwrap();
+ json["commitUser"] = format!("writer-{writer}").into();
+ let snapshot: Snapshot = serde_json::from_value(json).unwrap();
+ barrier.wait().await;
+ let won = manager.commit_snapshot(&snapshot).await.unwrap();
+ (snapshot, won)
+ }
+ }))
+ .await;
+ let winners = results.iter().filter(|(_, won)|
*won).collect::<Vec<_>>();
+ assert_eq!(winners.len(), 1);
+ let io = FileIOBuilder::new("file").build().unwrap();
+ let manager = SnapshotManager::new(io.clone(), table_path);
+ assert_eq!(manager.get_snapshot(1).await.unwrap(), winners[0].0);
+ assert_eq!(manager.get_latest_snapshot_id().await.unwrap(), Some(1));
+ assert_eq!(
+ io.list_status(&manager.snapshot_dir()).await.unwrap().len(),
+ 2
+ );
+ assert!(!manager.commit_snapshot(&test_snapshot(1)).await.unwrap());
+ assert_eq!(manager.get_snapshot(1).await.unwrap(), winners[0].0);
+ }
+
+ #[tokio::test]
+ async fn test_commit_snapshot_refuses_non_atomic_backend_without_writing()
{
+ let op =
opendal::Operator::from_config(opendal::services::MemoryConfig::default())
+ .unwrap()
+ .layer(opendal::layers::CapabilityOverrideLayer::new(|mut cap| {
+ cap.write_with_if_not_exists = false;
+ cap
+ }));
+ let io = FileIOBuilder::new("file")
+ .with_fs_operator(op.clone())
+ .build()
+ .unwrap();
+ let manager = SnapshotManager::new(io, "/unsafe-snapshot".into());
+ let error = manager
+ .commit_snapshot(&test_snapshot(1))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, crate::Error::IoUnsupported { .. }));
+ assert!(error.to_string().contains("SnapshotCommit"), "{error}");
+ assert!(
+ op.list("/").await.unwrap().is_empty(),
+ "unsafe backends must not receive even a staging write"
+ );
+ }
+
+ #[tokio::test]
+ async fn test_commit_snapshot_rename_error_never_falls_back_to_overwrite()
{
+ let op =
opendal::Operator::from_config(opendal::services::MemoryConfig::default())
+ .unwrap()
+ .layer(opendal::layers::CapabilityOverrideLayer::new(|mut cap| {
+ // Advertise rename but inject an Unsupported error from the
+ // underlying memory service. Conditional put remains
available.
+ cap.rename = true;
+ cap.rename_with_if_not_exists = true;
+ cap
+ }));
+ let io = FileIOBuilder::new("file")
+ .with_fs_operator(op.clone())
+ .build()
+ .unwrap();
+ let manager = SnapshotManager::new(io.clone(),
"/failed-rename".into());
+ let error = manager
+ .commit_snapshot(&test_snapshot(1))
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(error, crate::Error::IoUnexpected { ref source, .. } if
source.kind() == opendal::ErrorKind::Unsupported)
+ );
+ assert!(!io.exists(&manager.snapshot_path(1)).await.unwrap());
+ assert!(!io.exists(&manager.latest_hint_path()).await.unwrap());
+ }
+
+ #[cfg(feature = "storage-fs")]
+ #[tokio::test]
+ async fn
test_commit_snapshot_refuses_custom_fs_without_conditional_rename() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut config = opendal_service_fs::FsConfig::default();
+ config.root = Some(dir.path().to_str().unwrap().into());
+ let io = FileIOBuilder::new("file")
+ .with_fs_operator(opendal::Operator::from_config(config).unwrap())
+ .build()
+ .unwrap();
+ let manager = SnapshotManager::new(io, "file:/custom-table".into());
+ let error = manager
+ .commit_snapshot(&test_snapshot(1))
+ .await
+ .unwrap_err();
+ assert!(matches!(error, crate::Error::IoUnsupported { .. }));
+ assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
+ }
+
#[tokio::test]
async fn test_commit_updates_latest_hint() {
let (_, sm) = setup("memory:/test_commit_hint").await;
diff --git a/crates/paimon/src/table/table_commit/recovery_tests.rs
b/crates/paimon/src/table/table_commit/recovery_tests.rs
index fbe1d67f..8210166d 100644
--- a/crates/paimon/src/table/table_commit/recovery_tests.rs
+++ b/crates/paimon/src/table/table_commit/recovery_tests.rs
@@ -36,6 +36,119 @@ async fn empty_unpartitioned_overwrite_clears_old_rows() {
);
}
+struct RacingTablePublisher {
+ manager: SnapshotManager,
+ barrier: tokio::sync::Barrier,
+}
+
+#[async_trait::async_trait]
+impl SnapshotCommit for RacingTablePublisher {
+ async fn commit(
+ &self,
+ _: Option<&str>,
+ snapshot: &Snapshot,
+ _: &str,
+ _: &[PartitionStatistics],
+ ) -> Result<bool> {
+ if snapshot.id() == 1 {
+ self.barrier.wait().await;
+ }
+ self.manager.commit_snapshot(snapshot).await
+ }
+}
+
+#[tokio::test]
+async fn concurrent_appends_rebase_without_losing_files_or_reusing_row_ids() {
+ let io = test_file_io();
+ let path = "memory:/concurrent-append-publication";
+ setup_dirs(&io, path).await;
+ let table = test_table_with_options(
+ &io,
+ path,
+ HashMap::from([("row-tracking.enabled".into(), "true".into())]),
+ );
+ let publisher = Arc::new(RacingTablePublisher {
+ manager: table.snapshot_manager(),
+ barrier: tokio::sync::Barrier::new(2),
+ });
+ let mut first = TableCommit::new(table.clone(), "writer-a".into());
+ first.snapshot_commit = publisher.clone();
+ first.commit_min_retry_wait_ms = 0;
+ first.commit_max_retry_wait_ms = 0;
+ let mut second = TableCommit::new(table, "writer-b".into());
+ second.snapshot_commit = publisher;
+ second.commit_min_retry_wait_ms = 0;
+ second.commit_max_retry_wait_ms = 0;
+ let mut a = append_message("a.parquet");
+ let mut b = append_message("b.parquet");
+ a.new_files[0].file_source = Some(0);
+ b.new_files[0].file_source = Some(0);
+ let (a, b) = tokio::time::timeout(std::time::Duration::from_secs(5), async
{
+ tokio::join!(
+ first.commit_with_identifier(vec![a], 7),
+ second.commit_with_identifier(vec![b], 7)
+ )
+ })
+ .await
+ .expect("concurrent commits must complete");
+ a.unwrap();
+ b.unwrap();
+ let latest = latest_snapshot(&io, path).await.unwrap();
+ assert_eq!(latest.id(), 2);
+ assert_eq!(latest.total_record_count(), Some(20));
+ assert_eq!(latest.next_row_id(), Some(20));
+ let mut entries = active_entries(&io, path, &latest).await;
+ entries.sort_by_key(|entry| entry.file().first_row_id);
+ assert_eq!(entries.len(), 2);
+ assert_eq!(entries[0].file().first_row_id, Some(0));
+ assert_eq!(entries[1].file().first_row_id, Some(10));
+ assert_eq!(entries[0].file().row_count, 10);
+ assert_eq!(entries[1].file().row_count, 10);
+ let mut names = entries
+ .iter()
+ .map(|entry| entry.file().file_name.as_str())
+ .collect::<Vec<_>>();
+ names.sort_unstable();
+ assert_eq!(names, ["a.parquet", "b.parquet"]);
+}
+
+#[tokio::test]
+async fn unsupported_publication_preserves_prepared_data() {
+ let op =
opendal::Operator::from_config(opendal::services::MemoryConfig::default())
+ .unwrap()
+ .layer(opendal::layers::CapabilityOverrideLayer::new(|mut cap| {
+ cap.write_with_if_not_exists = false;
+ cap
+ }));
+ let io = FileIOBuilder::new("file")
+ .with_fs_operator(op)
+ .build()
+ .unwrap();
+ let path = "/unsupported-publication";
+ setup_dirs(&io, path).await;
+ let data_path = format!("{path}/bucket-0/data.parquet");
+ io.new_output(&data_path)
+ .unwrap()
+ .write(bytes::Bytes::from_static(b"prepared"))
+ .await
+ .unwrap();
+ let mut commit = setup_commit(&io, path);
+ commit.commit_max_retries = 0;
+ let error = commit
+ .commit(vec![append_message("data.parquet")])
+ .await
+ .unwrap_err();
+ assert!(
+ error.to_string().contains("cannot atomically publish"),
+ "{error}"
+ );
+ assert!(latest_snapshot(&io, path).await.is_none());
+ assert_eq!(
+ io.new_input(&data_path).unwrap().read().await.unwrap(),
+ "prepared"
+ );
+}
+
#[tokio::test]
async fn recovery_rejects_duplicate_file_after_identity_expiry() {
let io = test_file_io();
diff --git a/crates/paimon/tests/dynamic_bucket_write_test.rs
b/crates/paimon/tests/dynamic_bucket_write_test.rs
index de409ddd..ffeb2d92 100644
--- a/crates/paimon/tests/dynamic_bucket_write_test.rs
+++ b/crates/paimon/tests/dynamic_bucket_write_test.rs
@@ -231,6 +231,20 @@ async fn
failed_later_prepare_preserves_returned_and_committed_files() {
);
setup_dirs(&io, path).await;
let builder = table.new_write_builder();
+ // Inject HASH failures only during preparation. Publish the resulting
+ // messages through the built-in filesystem's atomic snapshot
primitive.
+ let commit_table = Table::new(
+ FileIOBuilder::new("file").build().unwrap(),
+ table.identifier().clone(),
+ path.into(),
+ table.schema().clone(),
+ None,
+ );
+ let committer = commit_table
+ .new_write_builder()
+ .with_commit_user(builder.commit_user())
+ .unwrap()
+ .new_commit();
let mut writer = builder.new_write().unwrap();
writer
.write_arrow_batch(&make_batch(vec![1], vec![10]))
@@ -247,7 +261,7 @@ async fn
failed_later_prepare_preserves_returned_and_committed_files() {
.collect::<std::collections::HashSet<_>>();
assert!(!returned.is_empty());
if publish_first {
- builder.new_commit().commit(messages.clone()).await.unwrap();
+ committer.commit(messages.clone()).await.unwrap();
}
writer
.write_arrow_batch(&make_batch(vec![2], vec![20]))
@@ -274,7 +288,7 @@ async fn
failed_later_prepare_preserves_returned_and_committed_files() {
);
}
if !publish_first {
- builder.new_commit().commit(messages).await.unwrap();
+ committer.commit(messages).await.unwrap();
}
let snapshot = table
.snapshot_manager()
diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md
index 811fe07b..26cb18f8 100644
--- a/docs/src/getting-started.md
+++ b/docs/src/getting-started.md
@@ -218,6 +218,24 @@ 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.
+Snapshot publication never replaces an existing snapshot. The built-in local
+filesystem stages a complete file before publishing it without replacement;
+memory and supported object stores use conditional creation. A publication
+conflict rebases the commit on the latest snapshot and retries. Backends
without
+atomic publication of a complete file must use a catalog-provided
`SnapshotCommit`
+with concurrency control; filesystem commits on those backends return an
+unsupported I/O error. This includes COS conditional writes, which do not
reliably
+prevent overwrites on buckets that have ever enabled versioning, and position
+writers such as ADLS and custom filesystem operators without conditional
rename.
+OSS conditional publication assumes the bucket uses its default versioning
+configuration and has never enabled versioning.
+Application storage providers can implement
`FileIOProvider::write_atomic_if_not_exists`
+to supply their own atomic publication primitive. FUSE routes use local staged
+publication and require the mount to support publishing without replacement.
+Storage failures never fall back to an unconditional snapshot write.
+All concurrent writers, including writers from other engines, must use
compatible
+concurrency control.
+
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.