This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new fb507b0e9a [common] Forward newTwoPhaseOutputStream through
ResolvingFileIO and resolve it at commit (#9655)
fb507b0e9a is described below
commit fb507b0e9a6397849959b0ec26b34c18572fcca3
Author: YangJie <[email protected]>
AuthorDate: Sun Sep 13 22:36:15 2026 -0400
[common] Forward newTwoPhaseOutputStream through ResolvingFileIO and
resolve it at commit (#9655)
---
.../paimon/fs/BaseMultiPartUploadCommitter.java | 12 +-
.../java/org/apache/paimon/fs/ResolvingFileIO.java | 11 +-
.../fs/BaseMultiPartUploadCommitterTest.java | 97 +++++++++++++++
.../org/apache/paimon/fs/ResolvingFileIOTest.java | 18 +++
.../org/apache/paimon/s3/S3MultiPartUpload.java | 32 ++---
.../paimon/s3/S3ResolvingTwoPhaseCommitITCase.java | 135 +++++++++++++++++++++
6 files changed, 277 insertions(+), 28 deletions(-)
diff --git
a/paimon-common/src/main/java/org/apache/paimon/fs/BaseMultiPartUploadCommitter.java
b/paimon-common/src/main/java/org/apache/paimon/fs/BaseMultiPartUploadCommitter.java
index 5245dcc161..9d16620dd2 100644
---
a/paimon-common/src/main/java/org/apache/paimon/fs/BaseMultiPartUploadCommitter.java
+++
b/paimon-common/src/main/java/org/apache/paimon/fs/BaseMultiPartUploadCommitter.java
@@ -24,6 +24,7 @@ import org.apache.paimon.rest.RESTTokenFileIO;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -80,8 +81,12 @@ public abstract class BaseMultiPartUploadCommitter<T, C>
implements TwoPhaseOutp
@Override
public void discardStaging(FileIO fileIO) throws IOException {
try {
- // Aborting an upload never deletes a possibly completed object.
+ // A completed or already-aborted upload has no staging left to
release, so a
+ // not-found upload (e.g. S3 NoSuchUpload) is nothing to discard
rather than a
+ // failure. Aborting never deletes a completed object, so the
target stays intact.
abortMultipartUpload(fileIO);
+ } catch (FileNotFoundException e) {
+ LOG.debug("Multipart upload {} already gone; nothing to discard.",
uploadId);
} catch (Exception e) {
throw new IOException("Failed to discard multipart upload with ID:
" + uploadId, e);
}
@@ -110,6 +115,11 @@ public abstract class BaseMultiPartUploadCommitter<T, C>
implements TwoPhaseOutp
RESTTokenFileIO restTokenFileIO = (RESTTokenFileIO) fileIO;
fileIO = restTokenFileIO.fileIO();
}
+ if (fileIO instanceof ResolvingFileIO) {
+ // The upload was started on the FileIO this resolver resolved to,
and
+ // multiPartUploadStore casts to that concrete type, so resolve
again here.
+ fileIO = ((ResolvingFileIO) fileIO).fileIO(targetPath());
+ }
return multiPartUploadStore(fileIO, targetPath());
}
}
diff --git
a/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java
b/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java
index 5568ba896c..cc3aba497e 100644
--- a/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java
+++ b/paimon-common/src/main/java/org/apache/paimon/fs/ResolvingFileIO.java
@@ -18,7 +18,6 @@
package org.apache.paimon.fs;
-import org.apache.paimon.annotation.VisibleForTesting;
import org.apache.paimon.catalog.CatalogContext;
import org.apache.paimon.data.BlobDescriptor;
import org.apache.paimon.options.CatalogOptions;
@@ -116,6 +115,15 @@ public class ResolvingFileIO implements FileIO {
return wrap(() -> fileIO(path).tryToWriteAtomic(path, content));
}
+ @Override
+ public TwoPhaseOutputStream newTwoPhaseOutputStream(Path path, boolean
overwrite)
+ throws IOException {
+ // Forward to the resolved FileIO so implementations with native
multipart
+ // commits (object storage) keep them; the interface default would
wrap this
+ // resolver in a rename-based committer instead.
+ return wrap(() -> fileIO(path).newTwoPhaseOutputStream(path,
overwrite));
+ }
+
@Override
public String createBlobPresignedUrl(
Path tableRoot, BlobDescriptor descriptor, Duration validity)
throws IOException {
@@ -125,7 +133,6 @@ public class ResolvingFileIO implements FileIO {
.createBlobPresignedUrl(tableRoot, descriptor,
validity));
}
- @VisibleForTesting
public FileIO fileIO(Path path) throws IOException {
CacheKey cacheKey = new CacheKey(path.toUri().getScheme(),
path.toUri().getAuthority());
return fileIOMap.computeIfAbsent(
diff --git
a/paimon-common/src/test/java/org/apache/paimon/fs/BaseMultiPartUploadCommitterTest.java
b/paimon-common/src/test/java/org/apache/paimon/fs/BaseMultiPartUploadCommitterTest.java
new file mode 100644
index 0000000000..c23fa5896f
--- /dev/null
+++
b/paimon-common/src/test/java/org/apache/paimon/fs/BaseMultiPartUploadCommitterTest.java
@@ -0,0 +1,97 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.fs;
+
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.options.Options;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** Tests for {@link BaseMultiPartUploadCommitter}. */
+public class BaseMultiPartUploadCommitterTest {
+
+ private static final Path TARGET = new
Path("oss://bucket/table/data-0.parquet");
+
+ private FileIO resolved;
+ private ResolvingFileIO resolvingFileIO;
+
+ @BeforeEach
+ public void setUp() throws IOException {
+ resolved = mock(FileIO.class);
+ FileIOLoader loader = mock(FileIOLoader.class);
+ when(loader.getScheme()).thenReturn("oss");
+ when(loader.load(any())).thenReturn(resolved);
+ resolvingFileIO = new ResolvingFileIO();
+ resolvingFileIO.configure(CatalogContext.create(new Options(), loader,
null));
+ }
+
+ @Test
+ public void testCommitResolvesResolvingFileIO() throws IOException {
+ RecordingCommitter committer = new RecordingCommitter();
+ committer.commit(resolvingFileIO);
+ // the subclasses cast this to their own concrete FileIO, so the
resolver itself
+ // reaching them would be a ClassCastException at commit time
+ assertThat(committer.received).isSameAs(resolved);
+ }
+
+ @Test
+ public void testDiscardStagingResolvesResolvingFileIO() throws IOException
{
+ RecordingCommitter committer = new RecordingCommitter();
+ committer.discardStaging(resolvingFileIO);
+ assertThat(committer.received).isSameAs(resolved);
+ }
+
+ @Test
+ public void testConcreteFileIOIsPassedThroughUnchanged() throws
IOException {
+ RecordingCommitter committer = new RecordingCommitter();
+ committer.commit(resolved);
+ assertThat(committer.received).isSameAs(resolved);
+ }
+
+ private static class RecordingCommitter extends
BaseMultiPartUploadCommitter<String, String> {
+
+ private FileIO received;
+
+ private RecordingCommitter() {
+ super(
+ "upload-id",
+ Collections.singletonList("part-1"),
+ "table/data-0.parquet",
+ 1L,
+ TARGET);
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ protected MultiPartUploadStore<String, String> multiPartUploadStore(
+ FileIO fileIO, Path targetPath) {
+ this.received = fileIO;
+ return mock(MultiPartUploadStore.class);
+ }
+ }
+}
diff --git
a/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java
b/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java
index 067c7da649..e5203833aa 100644
--- a/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java
+++ b/paimon-common/src/test/java/org/apache/paimon/fs/ResolvingFileIOTest.java
@@ -184,4 +184,22 @@ public class ResolvingFileIOTest {
// the interface default would have written a temp file and renamed it
instead
verify(delegate, never()).rename(any(), any());
}
+
+ @Test
+ public void testNewTwoPhaseOutputStreamReachesResolvedOverride() throws
IOException {
+ FileIO delegate = mock(FileIO.class);
+ FileIOLoader loader = mock(FileIOLoader.class);
+ when(loader.load(any())).thenReturn(delegate);
+ when(loader.getScheme()).thenReturn("oss");
+ resolvingFileIO.configure(CatalogContext.create(new Options(), loader,
null));
+
+ Path target = new Path("oss://bucket/table/data.parquet");
+ TwoPhaseOutputStream mockStream = mock(TwoPhaseOutputStream.class);
+ when(delegate.newTwoPhaseOutputStream(target,
false)).thenReturn(mockStream);
+
+ assertEquals(mockStream,
resolvingFileIO.newTwoPhaseOutputStream(target, false));
+ verify(delegate).newTwoPhaseOutputStream(target, false);
+ // the interface default would have renamed a temp file on the
resolver instead
+ verify(delegate, never()).rename(any(), any());
+ }
}
diff --git
a/paimon-filesystems/paimon-s3-impl/src/main/java/org/apache/paimon/s3/S3MultiPartUpload.java
b/paimon-filesystems/paimon-s3-impl/src/main/java/org/apache/paimon/s3/S3MultiPartUpload.java
index c995dd0881..3398b54aa2 100644
---
a/paimon-filesystems/paimon-s3-impl/src/main/java/org/apache/paimon/s3/S3MultiPartUpload.java
+++
b/paimon-filesystems/paimon-s3-impl/src/main/java/org/apache/paimon/s3/S3MultiPartUpload.java
@@ -26,9 +26,6 @@ import org.apache.hadoop.fs.Path;
import org.apache.hadoop.fs.s3a.S3AFileSystem;
import org.apache.hadoop.fs.s3a.WriteOperationHelper;
import org.apache.hadoop.fs.s3a.impl.PutObjectOptions;
-import org.apache.hadoop.fs.s3a.statistics.S3AStatisticsContext;
-import org.apache.hadoop.fs.store.audit.AuditSpan;
-import org.apache.hadoop.fs.store.audit.AuditSpanSource;
import software.amazon.awssdk.core.sync.RequestBody;
import
software.amazon.awssdk.services.s3.model.CompleteMultipartUploadResponse;
import software.amazon.awssdk.services.s3.model.CompletedPart;
@@ -48,18 +45,15 @@ public class S3MultiPartUpload
implements MultiPartUploadStore<CompletedPart,
CompleteMultipartUploadResponse> {
private final S3AFileSystem s3a;
- private final InternalWriteOperationHelper s3accessHelper;
+ private final WriteOperationHelper s3accessHelper;
public S3MultiPartUpload(S3AFileSystem s3a, Configuration conf) {
- checkNotNull(s3a);
- this.s3accessHelper =
- new InternalWriteOperationHelper(
- s3a,
- checkNotNull(conf),
- s3a.createStoreContext().getInstrumentation(),
- s3a.getAuditSpanSource(),
- s3a.getActiveAuditSpan());
- this.s3a = s3a;
+ this.s3a = checkNotNull(s3a);
+ // Take the helper from the file system instead of building it by
hand: the hand-built
+ // one left WriteOperationHelperCallbacks null, so uploadPart and
completeMultipartUpload
+ // dereferenced null against a real backend. getWriteOperationHelper
wires the same audit
+ // span and statistics plus the callbacks the AWS SDK v2 path needs.
+ this.s3accessHelper = s3a.getWriteOperationHelper();
}
@Override
@@ -117,16 +111,4 @@ public class S3MultiPartUpload
public void abortMultipartUpload(String destKey, String uploadId) throws
IOException {
s3accessHelper.abortMultipartUpload(destKey, uploadId, false, null);
}
-
- private static final class InternalWriteOperationHelper extends
WriteOperationHelper {
-
- InternalWriteOperationHelper(
- S3AFileSystem owner,
- Configuration conf,
- S3AStatisticsContext statisticsContext,
- AuditSpanSource auditSpanSource,
- AuditSpan auditSpan) {
- super(owner, conf, statisticsContext, auditSpanSource, auditSpan,
null);
- }
- }
}
diff --git
a/paimon-filesystems/paimon-s3-impl/src/test/java/org/apache/paimon/s3/S3ResolvingTwoPhaseCommitITCase.java
b/paimon-filesystems/paimon-s3-impl/src/test/java/org/apache/paimon/s3/S3ResolvingTwoPhaseCommitITCase.java
new file mode 100644
index 0000000000..0f35a6c054
--- /dev/null
+++
b/paimon-filesystems/paimon-s3-impl/src/test/java/org/apache/paimon/s3/S3ResolvingTwoPhaseCommitITCase.java
@@ -0,0 +1,135 @@
+/*
+ * 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.
+ */
+
+package org.apache.paimon.s3;
+
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.fs.FileIO;
+import org.apache.paimon.fs.FileIOLoader;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.RenamingTwoPhaseOutputStream;
+import org.apache.paimon.fs.ResolvingFileIO;
+import org.apache.paimon.fs.TwoPhaseOutputStream;
+import org.apache.paimon.options.Options;
+import org.apache.paimon.utils.InstantiationUtil;
+
+import org.apache.hadoop.conf.Configuration;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import java.nio.charset.StandardCharsets;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Integration test that a two-phase write routed through {@link
ResolvingFileIO} uses S3's native
+ * multipart-upload commit end to end against a MinIO backend: the stream is
the resolved override
+ * (not the rename fallback), its committer survives serialization, and
+ * commit/discard/discardStaging work when handed a fresh resolver that has to
resolve the scheme
+ * before casting to {@link S3FileIO}.
+ */
+class S3ResolvingTwoPhaseCommitITCase {
+
+ @RegisterExtension private static final MinioTestContainer MINIO = new
MinioTestContainer();
+
+ // preferIO resolves the s3 scheme to a native S3FileIO without any
ServiceLoader registration.
+ private static final FileIOLoader S3_LOADER =
+ new FileIOLoader() {
+ @Override
+ public String getScheme() {
+ return "s3";
+ }
+
+ @Override
+ public FileIO load(Path path) {
+ return new S3FileIO();
+ }
+ };
+
+ private ResolvingFileIO newResolver() {
+ ResolvingFileIO resolver = new ResolvingFileIO();
+ resolver.configure(
+ CatalogContext.create(
+ Options.fromMap(MINIO.getS3ConfigOptions()),
+ new Configuration(),
+ S3_LOADER,
+ null));
+ return resolver;
+ }
+
+ private Path target(String name) {
+ return new Path(MINIO.getS3UriForDefaultBucket() + "/two-phase/" +
name);
+ }
+
+ @Test
+ void nativeMultipartCommitThroughFreshResolver() throws Exception {
+ Path path = target(UUID.randomUUID() + ".data");
+ String payload = "native-multipart-payload";
+
+ TwoPhaseOutputStream out = newResolver().newTwoPhaseOutputStream(path,
true);
+ // The resolver must forward to S3's native stream, not fall back to a
copy-and-rename one.
+ assertThat(out).isNotInstanceOf(RenamingTwoPhaseOutputStream.class);
+ out.write(payload.getBytes(StandardCharsets.UTF_8));
+ TwoPhaseOutputStream.Committer committer = out.closeForCommit();
+
+ // The committer is handed across the commit boundary, so it has to
serialize.
+ byte[] bytes = InstantiationUtil.serializeObject(committer);
+ TwoPhaseOutputStream.Committer restored =
+ InstantiationUtil.deserializeObject(bytes,
getClass().getClassLoader());
+
+ // Not visible before commit; committing through a fresh resolver
forces the resolve that
+ // precedes the (S3FileIO) cast in BaseMultiPartUploadCommitter.
+ assertThat(newResolver().exists(path)).isFalse();
+ restored.commit(newResolver());
+
+ FileIO reader = newResolver();
+ assertThat(reader.exists(path)).isTrue();
+ assertThat(reader.readFileUtf8(path)).isEqualTo(payload);
+ reader.delete(path, false);
+ }
+
+ @Test
+ void abortBeforeCompletionLeavesNoObject() throws Exception {
+ Path path = target(UUID.randomUUID() + ".data");
+
+ TwoPhaseOutputStream out = newResolver().newTwoPhaseOutputStream(path,
true);
+ out.write("to-be-aborted".getBytes(StandardCharsets.UTF_8));
+ TwoPhaseOutputStream.Committer committer = out.closeForCommit();
+
+ committer.discard(newResolver());
+ assertThat(newResolver().exists(path)).isFalse();
+ }
+
+ @Test
+ void discardStagingAfterCommitPreservesObject() throws Exception {
+ Path path = target(UUID.randomUUID() + ".data");
+
+ TwoPhaseOutputStream out = newResolver().newTwoPhaseOutputStream(path,
true);
+ out.write("committed".getBytes(StandardCharsets.UTF_8));
+ TwoPhaseOutputStream.Committer committer = out.closeForCommit();
+
+ committer.commit(newResolver());
+ assertThat(newResolver().exists(path)).isTrue();
+
+ // Aborting staged resources after a successful commit must never
delete the object.
+ committer.discardStaging(newResolver());
+ assertThat(newResolver().exists(path)).isTrue();
+ newResolver().delete(path, false);
+ }
+}