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 f43c0d3ada [fs] Optimize OSS retry strategy for QpsLimitExceeded and 
5xx errors (#10299)
f43c0d3ada is described below

commit f43c0d3ada84da794a7794b70b16bf13ed4c4202
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Mon Sep 28 18:19:18 2026 +0800

    [fs] Optimize OSS retry strategy for QpsLimitExceeded and 5xx errors 
(#10299)
---
 .../main/java/org/apache/paimon/oss/OSSFileIO.java |  17 ++
 .../org/apache/paimon/oss/OSSRetryStrategy.java    |  75 ++++++
 .../apache/paimon/oss/OSSRetryStrategyTest.java    | 263 +++++++++++++++++++++
 3 files changed, 355 insertions(+)

diff --git 
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
 
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
index 3249cbfcef..17842c1f14 100644
--- 
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
+++ 
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSFileIO.java
@@ -91,6 +91,10 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
      * from the host it signs, which the server rejects with 
SignatureDoesNotMatch.
      */
     private static final String OSS_CNAME_ENABLED = "fs.oss.cname.enabled";
+
+    /** Set to false to use the OSS SDK default retry instead of {@link 
OSSRetryStrategy}. */
+    private static final String OSS_ENHANCED_RETRY_ENABLED = 
"fs.oss.enhanced-retry.enabled";
+
     // Paimon OSS SSE keys, mapping 1:1 to the OSS headers; they take 
precedence over hadoop's
     // server-side-encryption-algorithm.
     /** SSE method -> x-oss-server-side-encryption (AES256 / KMS / SM4). */
@@ -213,6 +217,10 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
                         throw new UncheckedIOException(e);
                     }
 
+                    if (hadoopOptions.getBoolean(OSS_ENHANCED_RETRY_ENABLED, 
true)) {
+                        setRetryStrategy(fs);
+                    }
+
                     if 
(hadoopOptions.getBoolean(OSS_SECOND_LEVEL_DOMAIN_ENABLED, false)) {
                         enableSecondLevelDomain(fs);
                     }
@@ -306,6 +314,15 @@ public class OSSFileIO extends HadoopCompliantFileIO 
implements HadoopOptionsPro
         }
     }
 
+    /** Retry every request of this file system, so writes and deletes ride 
out OSS throttling. */
+    private static void setRetryStrategy(AliyunOSSFileSystem fs) {
+        try {
+            getOssClient(fs).getClientConfiguration().setRetryStrategy(new 
OSSRetryStrategy());
+        } catch (Exception e) {
+            LOG.warn("Failed to set the OSS retry strategy, keeping the SDK 
default.", e);
+        }
+    }
+
     public void disableCname(AliyunOSSFileSystem fs) {
         try {
             setSupportCname(getOssClient(fs), false);
diff --git 
a/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSRetryStrategy.java
 
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSRetryStrategy.java
new file mode 100644
index 0000000000..e34ac1290b
--- /dev/null
+++ 
b/paimon-filesystems/paimon-oss-impl/src/main/java/org/apache/paimon/oss/OSSRetryStrategy.java
@@ -0,0 +1,75 @@
+/*
+ * 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.oss;
+
+import com.aliyun.oss.ClientErrorCode;
+import com.aliyun.oss.ClientException;
+import com.aliyun.oss.OSSErrorCode;
+import com.aliyun.oss.OSSException;
+import com.aliyun.oss.common.comm.RequestMessage;
+import com.aliyun.oss.common.comm.ResponseMessage;
+import com.aliyun.oss.common.comm.RetryStrategy;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ThreadLocalRandom;
+
+/**
+ * Retry strategy for every OSS request of {@link OSSFileIO}, so closing a 
data file or deleting
+ * files survives throttling (QpsLimitExceeded), 5xx and network errors 
instead of failing the job.
+ */
+class OSSRetryStrategy extends RetryStrategy {
+
+    private static final long BASE_DELAY_MILLIS = 300;
+    private static final long MAX_DELAY_MILLIS = 10_000;
+
+    private static final Set<Integer> RETRYABLE_STATUS =
+            new HashSet<>(Arrays.asList(429, 500, 502, 503, 504));
+
+    private static final Set<String> RETRYABLE_CLIENT_ERRORS =
+            new HashSet<>(
+                    Arrays.asList(
+                            ClientErrorCode.CONNECTION_TIMEOUT,
+                            ClientErrorCode.SOCKET_TIMEOUT,
+                            ClientErrorCode.CONNECTION_REFUSED,
+                            ClientErrorCode.UNKNOWN_HOST,
+                            ClientErrorCode.SOCKET_EXCEPTION,
+                            ClientErrorCode.SSL_EXCEPTION));
+
+    @Override
+    public boolean shouldRetry(
+            Exception ex, RequestMessage request, ResponseMessage response, 
int retries) {
+        if (ex instanceof ClientException) {
+            return RETRYABLE_CLIENT_ERRORS.contains(((ClientException) 
ex).getErrorCode());
+        }
+        if (ex instanceof OSSException
+                && OSSErrorCode.INVALID_RESPONSE.equals(((OSSException) 
ex).getErrorCode())) {
+            return false;
+        }
+        return response != null && 
RETRYABLE_STATUS.contains(response.getStatusCode());
+    }
+
+    /** Capped exponential backoff with jitter, so parallel writers of a table 
spread retries. */
+    @Override
+    public long getPauseDelay(int retries) {
+        long delay = Math.min(MAX_DELAY_MILLIS, BASE_DELAY_MILLIS << 
Math.min(retries, 16));
+        return delay / 2 + ThreadLocalRandom.current().nextLong(delay / 2 + 1);
+    }
+}
diff --git 
a/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSRetryStrategyTest.java
 
b/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSRetryStrategyTest.java
new file mode 100644
index 0000000000..c00d5b0d02
--- /dev/null
+++ 
b/paimon-filesystems/paimon-oss-impl/src/test/java/org/apache/paimon/oss/OSSRetryStrategyTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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.oss;
+
+import org.apache.paimon.catalog.CatalogContext;
+import org.apache.paimon.fs.Path;
+import org.apache.paimon.fs.PositionOutputStream;
+import org.apache.paimon.options.Options;
+
+import com.aliyun.oss.ClientException;
+import com.aliyun.oss.OSSException;
+import com.aliyun.oss.common.comm.ResponseMessage;
+import com.aliyun.oss.model.DeleteObjectsRequest;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/** Tests for {@link OSSRetryStrategy}, end to end against a fake OSS 
endpoint. */
+public class OSSRetryStrategyTest {
+
+    private static final int PART_SIZE = 100 * 1024;
+
+    private HttpServer server;
+    private final AtomicInteger completeCalls = new AtomicInteger();
+    private final AtomicInteger deleteObjectsCalls = new AtomicInteger();
+    private volatile int throttledCompletes;
+    private volatile int throttledDeletes;
+
+    @BeforeEach
+    public void startServer() throws IOException {
+        server = HttpServer.create(new 
InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
+        server.createContext("/", this::handle);
+        server.start();
+    }
+
+    @AfterEach
+    public void stopServer() {
+        server.stop(0);
+    }
+
+    @Test
+    public void testThrottledCompleteMultipartUploadIsRetried() throws 
IOException {
+        throttledCompletes = 2;
+
+        writeMultipartFile(fileIO(10));
+
+        assertThat(completeCalls).hasValue(3);
+    }
+
+    @Test
+    public void testCompleteMultipartUploadGivesUpAfterMaxAttempts() {
+        throttledCompletes = Integer.MAX_VALUE;
+
+        assertThatThrownBy(() -> writeMultipartFile(fileIO(2)))
+                .hasStackTraceContaining("QpsLimitExceeded");
+        assertThat(completeCalls).hasValue(3);
+    }
+
+    @Test
+    public void testDisabledFallsBackToSdkRetry() {
+        throttledCompletes = 1;
+        OSSFileIO fileIO = fileIO(10, false);
+
+        assertThatThrownBy(() -> writeMultipartFile(fileIO))
+                .hasStackTraceContaining("QpsLimitExceeded");
+        assertThat(completeCalls).hasValue(1);
+    }
+
+    @Test
+    public void testThrottledDeleteObjectsIsRetried() throws Exception {
+        throttledDeletes = 1;
+        Path path = new Path("oss://bucket/dir/file");
+
+        fileIO(10)
+                .ossClient(path)
+                .deleteObjects(
+                        new DeleteObjectsRequest("bucket")
+                                
.withKeys(Collections.singletonList("dir/file")));
+
+        assertThat(deleteObjectsCalls).hasValue(2);
+    }
+
+    @Test
+    public void testRetriesThrottlingAndServerErrorsOnly() {
+        OSSRetryStrategy strategy = new OSSRetryStrategy();
+        for (int status : new int[] {429, 500, 502, 503, 504}) {
+            assertThat(
+                            strategy.shouldRetry(
+                                    serverError("QpsLimitExceeded"), null, 
status(status), 0))
+                    .as("status %s", status)
+                    .isTrue();
+        }
+        for (int status : new int[] {400, 403, 404, 409, 501}) {
+            assertThat(strategy.shouldRetry(serverError("NoSuchUpload"), null, 
status(status), 0))
+                    .as("status %s", status)
+                    .isFalse();
+        }
+        assertThat(strategy.shouldRetry(serverError("InvalidResponse"), null, 
status(503), 0))
+                .isFalse();
+    }
+
+    @Test
+    public void testRetriesNetworkErrorsOnly() {
+        OSSRetryStrategy strategy = new OSSRetryStrategy();
+        assertThat(strategy.shouldRetry(clientError("SocketTimeout"), null, 
null, 0)).isTrue();
+        assertThat(strategy.shouldRetry(clientError("ConnectionTimeout"), 
null, null, 0)).isTrue();
+        assertThat(strategy.shouldRetry(clientError("SocketException"), null, 
null, 0)).isTrue();
+        assertThat(strategy.shouldRetry(clientError("NonRepeatableRequest"), 
null, null, 0))
+                .isFalse();
+        assertThat(strategy.shouldRetry(clientError("Unknown"), null, null, 
0)).isFalse();
+    }
+
+    @Test
+    public void testPauseDelayIsJitteredAndCapped() {
+        OSSRetryStrategy strategy = new OSSRetryStrategy();
+        for (int retries = 1; retries <= 100; retries++) {
+            long ceiling = retries < 6 ? 300L << retries : 10_000;
+            for (int i = 0; i < 100; i++) {
+                assertThat(strategy.getPauseDelay(retries)).isBetween(ceiling 
/ 2, ceiling);
+            }
+        }
+    }
+
+    private static OSSException serverError(String code) {
+        return new OSSException("failed", code, "request-id", "host", null, 
null, "POST");
+    }
+
+    private static ClientException clientError(String code) {
+        return new ClientException("failed", code, "request-id");
+    }
+
+    private static ResponseMessage status(int status) {
+        ResponseMessage response = new ResponseMessage(null);
+        response.setStatusCode(status);
+        return response;
+    }
+
+    private void writeMultipartFile(OSSFileIO fileIO) throws IOException {
+        try (PositionOutputStream out =
+                fileIO.newOutputStream(new Path("oss://bucket/dir/file"), 
false)) {
+            out.write(new byte[PART_SIZE + PART_SIZE / 2]);
+        }
+    }
+
+    private OSSFileIO fileIO(int maxAttempts) {
+        return fileIO(maxAttempts, true);
+    }
+
+    private OSSFileIO fileIO(int maxAttempts, boolean enhancedRetry) {
+        Options options = new Options();
+        options.set("fs.oss.enhanced-retry.enabled", 
String.valueOf(enhancedRetry));
+        options.set("fs.oss.endpoint", "http://127.0.0.1:"; + 
server.getAddress().getPort());
+        options.set("fs.oss.accessKeyId", "ak");
+        options.set("fs.oss.accessKeySecret", "sk");
+        options.set("fs.oss.attempts.maximum", String.valueOf(maxAttempts));
+        options.set("fs.oss.multipart.upload.size", String.valueOf(PART_SIZE));
+        options.set("file-io.allow-cache", "false");
+        OSSFileIO fileIO = new OSSFileIO();
+        fileIO.configure(CatalogContext.create(options));
+        return fileIO;
+    }
+
+    private void handle(HttpExchange exchange) throws IOException {
+        try (InputStream in = exchange.getRequestBody()) {
+            while (in.read() >= 0) {
+                // drain the request body
+            }
+        }
+        String method = exchange.getRequestMethod();
+        String query = exchange.getRequestURI().getRawQuery();
+        query = query == null ? "" : query;
+        exchange.getResponseHeaders().add("x-oss-request-id", 
"fake-request-id");
+        if ("HEAD".equals(method)) {
+            exchange.sendResponseHeaders(404, -1);
+        } else if ("GET".equals(method)) {
+            respond(
+                    exchange,
+                    200,
+                    "<ListBucketResult><Name>bucket</Name><MaxKeys>1</MaxKeys>"
+                            + 
"<IsTruncated>false</IsTruncated><KeyCount>0</KeyCount>"
+                            + "</ListBucketResult>");
+        } else if ("POST".equals(method) && query.contains("uploads")) {
+            respond(
+                    exchange,
+                    200,
+                    
"<InitiateMultipartUploadResult><Bucket>bucket</Bucket><Key>dir/file</Key>"
+                            + 
"<UploadId>upload-id</UploadId></InitiateMultipartUploadResult>");
+        } else if ("PUT".equals(method)) {
+            exchange.getResponseHeaders().add("ETag", "\"etag\"");
+            exchange.sendResponseHeaders(200, -1);
+        } else if ("POST".equals(method) && query.contains("uploadId")) {
+            if (completeCalls.incrementAndGet() <= throttledCompletes) {
+                respondThrottled(exchange);
+            } else {
+                respond(
+                        exchange,
+                        200,
+                        
"<CompleteMultipartUploadResult><Bucket>bucket</Bucket>"
+                                + "<Key>dir/file</Key><ETag>\"etag\"</ETag>"
+                                + "</CompleteMultipartUploadResult>");
+            }
+        } else if ("POST".equals(method) && query.contains("delete")) {
+            if (deleteObjectsCalls.incrementAndGet() <= throttledDeletes) {
+                respondThrottled(exchange);
+            } else {
+                respond(exchange, 200, "<DeleteResult></DeleteResult>");
+            }
+        } else {
+            exchange.sendResponseHeaders(400, -1);
+        }
+        exchange.close();
+    }
+
+    private static void respondThrottled(HttpExchange exchange) throws 
IOException {
+        respond(
+                exchange,
+                503,
+                "<Error><Code>QpsLimitExceeded</Code>"
+                        + "<Message>Please reduce your request rate.</Message>"
+                        + 
"<RequestId>fake-request-id</RequestId><HostId>fake</HostId></Error>");
+    }
+
+    private static void respond(HttpExchange exchange, int status, String xml) 
throws IOException {
+        byte[] body =
+                ("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + xml)
+                        .getBytes(StandardCharsets.UTF_8);
+        exchange.getResponseHeaders().add("Content-Type", "application/xml");
+        exchange.sendResponseHeaders(status, body.length);
+        try (OutputStream out = exchange.getResponseBody()) {
+            out.write(body);
+        }
+    }
+}

Reply via email to