This is an automated email from the ASF dual-hosted git repository.
JNSimba pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris-spark-connector.git
The following commit(s) were added to refs/heads/master by this push:
new 9675be3 [feat] Support IAM roles and gzip for S3 TVF sink (#382)
9675be3 is described below
commit 9675be392bd4876de717dc2c2d0e2f7afb5387aa
Author: wudi <[email protected]>
AuthorDate: Fri Sep 18 10:03:18 2026 +0800
[feat] Support IAM roles and gzip for S3 TVF sink (#382)
---
.../spark-doris-connector-base/pom.xml | 15 +++
.../client/write/tvf/S3ClientObjectStore.java | 71 +++++++++-
.../spark/client/write/tvf/S3TvfCommitter.java | 4 +-
.../spark/client/write/tvf/S3TvfSqlBuilder.java | 23 +++-
.../doris/spark/client/write/tvf/S3TvfWriter.java | 20 ++-
.../apache/doris/spark/config/DorisOptions.java | 4 +
.../apache/doris/spark/config/S3TvfOptions.java | 74 +++++++++-
.../client/write/tvf/S3ClientObjectStoreTest.java | 14 ++
.../spark/client/write/tvf/S3TvfCommitterTest.java | 57 ++++++++
.../client/write/tvf/S3TvfSqlBuilderTest.java | 48 +++++++
.../doris/spark/config/S3TvfOptionsTest.java | 86 ++++++++++++
.../doris/spark/sql/S3TvfIamRoleITCase.scala | 150 +++++++++++++++++++++
.../apache/doris/spark/sql/S3TvfSinkITCase.scala | 38 +++++-
13 files changed, 584 insertions(+), 20 deletions(-)
diff --git a/spark-doris-connector/spark-doris-connector-base/pom.xml
b/spark-doris-connector/spark-doris-connector-base/pom.xml
index 4e24e7f..ea44406 100644
--- a/spark-doris-connector/spark-doris-connector-base/pom.xml
+++ b/spark-doris-connector/spark-doris-connector-base/pom.xml
@@ -223,6 +223,21 @@
</exclusion>
</exclusions>
</dependency>
+ <dependency>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>sts</artifactId>
+ <version>${awssdk.version}</version>
+ <exclusions>
+ <exclusion>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>apache-client</artifactId>
+ </exclusion>
+ <exclusion>
+ <groupId>software.amazon.awssdk</groupId>
+ <artifactId>netty-nio-client</artifactId>
+ </exclusion>
+ </exclusions>
+ </dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>url-connection-client</artifactId>
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStore.java
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStore.java
index 882adf6..ccb3854 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStore.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStore.java
@@ -19,6 +19,8 @@ package org.apache.doris.spark.client.write.tvf;
import org.apache.doris.spark.config.S3TvfOptions;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.http.urlconnection.UrlConnectionHttpClient;
@@ -26,6 +28,9 @@ import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.sts.StsClient;
+import
software.amazon.awssdk.services.sts.auth.StsAssumeRoleCredentialsProvider;
+import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -34,12 +39,17 @@ import java.net.URI;
/** AWS SDK based implementation for S3-compatible object storage. */
public final class S3ClientObjectStore implements S3ObjectStore {
private static final String JSON_LINES_CONTENT_TYPE =
"application/x-ndjson";
+ private static final String ROLE_SESSION_NAME = "doris-spark-connector";
private final S3Client client;
private final String bucket;
+ private DefaultCredentialsProvider defaultCredentialsProvider;
+ private StsClient stsClient;
+ private StsAssumeRoleCredentialsProvider assumeRoleCredentialsProvider;
public S3ClientObjectStore(S3TvfOptions options) {
- this(createClient(options), options.getBucket());
+ this.bucket = options.getBucket();
+ this.client = createClient(options);
}
S3ClientObjectStore(S3Client client, String bucket) {
@@ -47,14 +57,11 @@ public final class S3ClientObjectStore implements
S3ObjectStore {
this.bucket = bucket;
}
- private static S3Client createClient(S3TvfOptions options) {
+ private S3Client createClient(S3TvfOptions options) {
return S3Client.builder()
.endpointOverride(URI.create(options.getEndpoint()))
.region(Region.of(options.getRegion()))
- .credentialsProvider(
- StaticCredentialsProvider.create(
- AwsBasicCredentials.create(
- options.getAccessKey(),
options.getSecretKey())))
+ .credentialsProvider(createCredentialsProvider(options))
.httpClientBuilder(UrlConnectionHttpClient.builder())
.serviceConfiguration(
S3Configuration.builder()
@@ -63,6 +70,44 @@ public final class S3ClientObjectStore implements
S3ObjectStore {
.build();
}
+ private AwsCredentialsProvider createCredentialsProvider(S3TvfOptions
options) {
+ if (!options.hasRoleArn()) {
+ return staticCredentialsProvider(options);
+ }
+ AwsCredentialsProvider sourceCredentialsProvider;
+ if (options.hasStaticCredentials()) {
+ sourceCredentialsProvider = staticCredentialsProvider(options);
+ } else {
+ defaultCredentialsProvider =
DefaultCredentialsProvider.builder().build();
+ sourceCredentialsProvider = defaultCredentialsProvider;
+ }
+ stsClient = StsClient.builder()
+ .region(Region.of(options.getRegion()))
+ .credentialsProvider(sourceCredentialsProvider)
+ .httpClientBuilder(UrlConnectionHttpClient.builder())
+ .build();
+ assumeRoleCredentialsProvider =
StsAssumeRoleCredentialsProvider.builder()
+ .stsClient(stsClient)
+ .refreshRequest(buildAssumeRoleRequest(options))
+ .build();
+ return assumeRoleCredentialsProvider;
+ }
+
+ static AssumeRoleRequest buildAssumeRoleRequest(S3TvfOptions options) {
+ AssumeRoleRequest.Builder request = AssumeRoleRequest.builder()
+ .roleArn(options.getRoleArn())
+ .roleSessionName(ROLE_SESSION_NAME);
+ if (options.getExternalId() != null) {
+ request.externalId(options.getExternalId());
+ }
+ return request.build();
+ }
+
+ private static StaticCredentialsProvider
staticCredentialsProvider(S3TvfOptions options) {
+ return StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(options.getAccessKey(),
options.getSecretKey()));
+ }
+
@Override
public void put(String objectKey, byte[] content) throws IOException {
PutObjectRequest request = PutObjectRequest.builder()
@@ -82,6 +127,18 @@ public final class S3ClientObjectStore implements
S3ObjectStore {
@Override
public void close() {
- client.close();
+ try {
+ client.close();
+ } finally {
+ if (assumeRoleCredentialsProvider != null) {
+ assumeRoleCredentialsProvider.close();
+ }
+ if (stsClient != null) {
+ stsClient.close();
+ }
+ if (defaultCredentialsProvider != null) {
+ defaultCredentialsProvider.close();
+ }
+ }
}
}
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitter.java
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitter.java
index 540972b..6c1e330 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitter.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitter.java
@@ -36,6 +36,7 @@ public final class S3TvfCommitter implements AutoCloseable {
private static final String PARTIAL_COLUMNS = "partial_columns";
private static final String FORMAT = "format";
private static final String READ_JSON_BY_LINE = "read_json_by_line";
+ private static final String COMPRESS_TYPE = "compress_type";
private static final String ENABLE_UNIQUE_KEY_PARTIAL_UPDATE =
"enable_unique_key_partial_update";
@@ -92,7 +93,8 @@ public final class S3TvfCommitter implements AutoCloseable {
if (!COLUMNS.equals(name)
&& !PARTIAL_COLUMNS.equals(name)
&& !FORMAT.equals(name)
- && !READ_JSON_BY_LINE.equals(name)) {
+ && !READ_JSON_BY_LINE.equals(name)
+ && !COMPRESS_TYPE.equals(name)) {
values.put(name, entry.getValue());
}
}
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilder.java
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilder.java
index 0fbd025..2f8ea64 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilder.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilder.java
@@ -41,6 +41,7 @@ public final class S3TvfSqlBuilder {
}
String columnSql = joinIdentifiers(columns);
String uri = buildUri(objectKeys);
+ String credentials = buildCredentials();
return "INSERT INTO "
+ TvfSqlUtils.quoteIdentifier(committable.getDatabase())
+ "."
@@ -58,18 +59,34 @@ public final class S3TvfSqlBuilder {
+ ","
+ property("read_json_by_line", "true")
+ ","
+ + (options.isGzipCompressionEnabled()
+ ? property("compress_type", "gz") + ","
+ : "")
+ property("s3.endpoint", options.getEndpoint())
+ ","
+ property("s3.region", options.getRegion())
+ ","
- + property("s3.access_key", options.getAccessKey())
- + ","
- + property("s3.secret_key", options.getSecretKey())
+ + credentials
+ ","
+ property("use_path_style",
Boolean.toString(options.isPathStyleAccess()))
+ ")";
}
+ private String buildCredentials() {
+ StringJoiner credentials = new StringJoiner(",");
+ if (options.hasStaticCredentials()) {
+ credentials.add(property("s3.access_key", options.getAccessKey()));
+ credentials.add(property("s3.secret_key", options.getSecretKey()));
+ }
+ if (options.hasRoleArn()) {
+ credentials.add(property("s3.role_arn", options.getRoleArn()));
+ if (options.getExternalId() != null) {
+ credentials.add(property("s3.external_id",
options.getExternalId()));
+ }
+ }
+ return credentials.toString();
+ }
+
private String buildUri(List<String> objectKeys) {
if (objectKeys.size() == 1) {
return "s3://" + options.getBucket() + "/" + objectKeys.get(0);
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfWriter.java
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfWriter.java
index 135f08e..3e4d754 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfWriter.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/client/write/tvf/S3TvfWriter.java
@@ -32,6 +32,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
+import java.util.zip.GZIPOutputStream;
/** Writes one logical Spark partition as deterministic JSON Lines objects. */
public final class S3TvfWriter implements AutoCloseable {
@@ -104,22 +105,25 @@ public final class S3TvfWriter implements AutoCloseable {
return;
}
byte[] content = buffer.toByteArray();
+ boolean gzipEnabled = options.isGzipCompressionEnabled();
int currentFileNumber = fileNumber++;
String fileName = String.format(
- "%s_%s_%s_%d_%d.json",
+ "%s_%s_%s_%d_%d.json%s",
labelPrefix,
normalizedTable,
batchUuid,
partitionId,
- currentFileNumber);
+ currentFileNumber,
+ gzipEnabled ? ".gz" : "");
String prefix = options.getPrefix();
String objectKey = prefix + (prefix.endsWith("/") ? "" : "/") +
fileName;
long uploadStartedAtNanos = System.nanoTime();
try {
- objectStore.put(objectKey, content);
+ byte[] uploadContent = gzipEnabled ? gzip(content) : content;
+ objectStore.put(objectKey, uploadContent);
LOG.info("S3 TVF object upload completed, objectKey={},
sizeBytes={}, uploadTimeMs={}.",
objectKey,
- content.length,
+ uploadContent.length,
TimeUnit.NANOSECONDS.toMillis(System.nanoTime() -
uploadStartedAtNanos));
} catch (IOException | RuntimeException e) {
LOG.warn("S3 TVF object upload failed, objectKey={}, sizeBytes={},
uploadTimeMs={}.",
@@ -134,6 +138,14 @@ public final class S3TvfWriter implements AutoCloseable {
recordCount = 0;
}
+ private static byte[] gzip(byte[] content) throws IOException {
+ ByteArrayOutputStream output = new ByteArrayOutputStream();
+ try (GZIPOutputStream gzip = new GZIPOutputStream(output)) {
+ gzip.write(content);
+ }
+ return output.toByteArray();
+ }
+
private String label() {
return labelPrefix
+ "_"
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisOptions.java
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisOptions.java
index 3d1b3a2..5afcfa5 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisOptions.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/DorisOptions.java
@@ -146,6 +146,10 @@ public class DorisOptions {
public static final ConfigOption<String> DORIS_SINK_S3_SECRET_KEY =
ConfigOptions.name("doris.sink.s3.secret-key").stringType().withoutDefaultValue().withDescription("Secret
key of the S3-compatible object storage.");
+ public static final ConfigOption<String> DORIS_SINK_S3_ROLE_ARN =
ConfigOptions.name("doris.sink.s3.role-arn").stringType().withoutDefaultValue().withDescription("AWS
IAM role ARN used to access the S3 object storage.");
+
+ public static final ConfigOption<String> DORIS_SINK_S3_EXTERNAL_ID =
ConfigOptions.name("doris.sink.s3.external-id").stringType().withoutDefaultValue().withDescription("External
ID used when assuming the AWS IAM role.");
+
public static final ConfigOption<Boolean> DORIS_SINK_S3_PATH_STYLE_ACCESS
=
ConfigOptions.name("doris.sink.s3.path-style-access").booleanType().defaultValue(false).withDescription("Whether
to use path-style access for object storage.");
public static final ConfigOption<Integer> DORIS_THRIFT_MAX_MESSAGE_SIZE =
ConfigOptions.name("doris.thrift.max.message.size").intType().defaultValue(Integer.MAX_VALUE).withDescription("")
;
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/S3TvfOptions.java
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/S3TvfOptions.java
index 399a3ba..ee2ce07 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/S3TvfOptions.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/main/java/org/apache/doris/spark/config/S3TvfOptions.java
@@ -28,6 +28,7 @@ public final class S3TvfOptions implements Serializable {
private static final long serialVersionUID = 1L;
private static final String FORMAT = "format";
private static final String READ_JSON_BY_LINE = "read_json_by_line";
+ private static final String COMPRESS_TYPE = "compress_type";
private final String endpoint;
private final String region;
@@ -35,6 +36,9 @@ public final class S3TvfOptions implements Serializable {
private final String prefix;
private final String accessKey;
private final String secretKey;
+ private final String roleArn;
+ private final String externalId;
+ private final boolean gzipCompressionEnabled;
private final boolean pathStyleAccess;
private S3TvfOptions(
@@ -44,6 +48,9 @@ public final class S3TvfOptions implements Serializable {
String prefix,
String accessKey,
String secretKey,
+ String roleArn,
+ String externalId,
+ boolean gzipCompressionEnabled,
boolean pathStyleAccess) {
this.endpoint = endpoint;
this.region = region;
@@ -51,6 +58,9 @@ public final class S3TvfOptions implements Serializable {
this.prefix = prefix;
this.accessKey = accessKey;
this.secretKey = secretKey;
+ this.roleArn = roleArn;
+ this.externalId = externalId;
+ this.gzipCompressionEnabled = gzipCompressionEnabled;
this.pathStyleAccess = pathStyleAccess;
}
@@ -60,8 +70,11 @@ public final class S3TvfOptions implements Serializable {
String region = required(config, DorisOptions.DORIS_SINK_S3_REGION);
String bucket = required(config, DorisOptions.DORIS_SINK_S3_BUCKET);
String prefix = required(config, DorisOptions.DORIS_SINK_S3_PREFIX);
- String accessKey = required(config,
DorisOptions.DORIS_SINK_S3_ACCESS_KEY);
- String secretKey = required(config,
DorisOptions.DORIS_SINK_S3_SECRET_KEY);
+ String accessKey = optional(config,
DorisOptions.DORIS_SINK_S3_ACCESS_KEY);
+ String secretKey = optional(config,
DorisOptions.DORIS_SINK_S3_SECRET_KEY);
+ String roleArn = optional(config, DorisOptions.DORIS_SINK_S3_ROLE_ARN);
+ String externalId = optional(config,
DorisOptions.DORIS_SINK_S3_EXTERNAL_ID);
+ validateCredentials(accessKey, secretKey, roleArn, externalId);
validatePrefix(prefix);
return new S3TvfOptions(
@@ -71,9 +84,37 @@ public final class S3TvfOptions implements Serializable {
prefix,
accessKey,
secretKey,
+ roleArn,
+ externalId,
+ isGzipCompressionEnabled(config.getSinkProperties()),
config.getValue(DorisOptions.DORIS_SINK_S3_PATH_STYLE_ACCESS));
}
+ private static String optional(DorisConfig config, ConfigOption<String>
option)
+ throws OptionRequiredException {
+ if (!config.contains(option)) {
+ return null;
+ }
+ String value = config.getValue(option).trim();
+ return value.isEmpty() ? null : value;
+ }
+
+ private static void validateCredentials(
+ String accessKey, String secretKey, String roleArn, String
externalId) {
+ if ((accessKey == null) != (secretKey == null)) {
+ throw new IllegalArgumentException(
+ "doris.sink.s3.access-key and doris.sink.s3.secret-key
must be configured together");
+ }
+ if (accessKey == null && roleArn == null) {
+ throw new IllegalArgumentException(
+ "S3 TVF requires either access/secret keys or
doris.sink.s3.role-arn");
+ }
+ if (externalId != null && roleArn == null) {
+ throw new IllegalArgumentException(
+ "doris.sink.s3.external-id requires
doris.sink.s3.role-arn");
+ }
+ }
+
private static String required(DorisConfig config, ConfigOption<String>
option)
throws OptionRequiredException {
String value = config.getValue(option).trim();
@@ -93,6 +134,15 @@ public final class S3TvfOptions implements Serializable {
throw new IllegalArgumentException(
"TVF write mode requires
'doris.sink.properties.read_json_by_line' to be true");
}
+ String compressType = loadProperties.getOrDefault(COMPRESS_TYPE,
"gz").trim();
+ if (!compressType.isEmpty() && !"gz".equalsIgnoreCase(compressType)) {
+ throw new IllegalArgumentException(
+ "TVF write mode only supports 'gz' or an empty
compress_type");
+ }
+ }
+
+ private static boolean isGzipCompressionEnabled(Map<String, String>
loadProperties) {
+ return
"gz".equalsIgnoreCase(loadProperties.getOrDefault(COMPRESS_TYPE, "gz").trim());
}
private static void validatePrefix(String prefix) {
@@ -129,6 +179,26 @@ public final class S3TvfOptions implements Serializable {
return secretKey;
}
+ public String getRoleArn() {
+ return roleArn;
+ }
+
+ public String getExternalId() {
+ return externalId;
+ }
+
+ public boolean hasRoleArn() {
+ return roleArn != null;
+ }
+
+ public boolean hasStaticCredentials() {
+ return accessKey != null;
+ }
+
+ public boolean isGzipCompressionEnabled() {
+ return gzipCompressionEnabled;
+ }
+
public boolean isPathStyleAccess() {
return pathStyleAccess;
}
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStoreTest.java
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStoreTest.java
index ae84933..24ef12b 100644
---
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStoreTest.java
+++
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3ClientObjectStoreTest.java
@@ -17,18 +17,32 @@
package org.apache.doris.spark.client.write.tvf;
+import org.apache.doris.spark.config.S3TvfOptions;
+import org.apache.doris.spark.config.S3TvfOptionsTest;
import org.junit.Assert;
import org.junit.Test;
import software.amazon.awssdk.core.sync.RequestBody;
import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectResponse;
+import software.amazon.awssdk.services.sts.model.AssumeRoleRequest;
import java.io.DataInputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public class S3ClientObjectStoreTest {
+ @Test
+ public void buildsAssumeRoleRequest() throws Exception {
+ S3TvfOptions options =
S3TvfOptions.fromConfig(S3TvfOptionsTest.configWithRole());
+
+ AssumeRoleRequest request =
S3ClientObjectStore.buildAssumeRoleRequest(options);
+
+ Assert.assertEquals("arn:aws:iam::123456789012:role/doris",
request.roleArn());
+ Assert.assertEquals("external-id", request.externalId());
+ Assert.assertEquals("doris-spark-connector",
request.roleSessionName());
+ }
+
@Test
public void uploadBodyIsRepeatableWithoutCopying() throws Exception {
byte[] content = "{\"id\":1}\n".getBytes(StandardCharsets.UTF_8);
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitterTest.java
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitterTest.java
new file mode 100644
index 0000000..81d8b67
--- /dev/null
+++
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3TvfCommitterTest.java
@@ -0,0 +1,57 @@
+// 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.doris.spark.client.write.tvf;
+
+import org.apache.doris.spark.config.S3TvfOptions;
+import org.apache.doris.spark.config.S3TvfOptionsTest;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.io.IOException;
+import java.sql.SQLException;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
+
+public class S3TvfCommitterTest {
+
+ @Test
+ public void excludesCompressTypeFromSessionVariables() throws Exception {
+ Map<String, String> loadProperties = new HashMap<>();
+ loadProperties.put("compress_type", "");
+ AtomicReference<Map<String, String>> captured = new
AtomicReference<>();
+ S3TvfLoadClient loadClient = new S3TvfLoadClient() {
+ @Override
+ public void executeInsert(String sql, Map<String, String>
sessionVariables)
+ throws SQLException {
+ captured.set(sessionVariables);
+ }
+
+ @Override
+ public void close() throws IOException {}
+ };
+ S3TvfOptions options =
S3TvfOptions.fromConfig(S3TvfOptionsTest.configWithRole());
+ S3TvfCommitter committer = new S3TvfCommitter(options, loadProperties,
loadClient);
+
+ committer.commit(new S3TvfCommittable(
+ "db", "tbl", "label", Arrays.asList("file.json"),
Arrays.asList("id")));
+
+ Assert.assertFalse(captured.get().containsKey("compress_type"));
+ }
+}
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilderTest.java
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilderTest.java
new file mode 100644
index 0000000..171b830
--- /dev/null
+++
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/client/write/tvf/S3TvfSqlBuilderTest.java
@@ -0,0 +1,48 @@
+// 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.doris.spark.client.write.tvf;
+
+import org.apache.doris.spark.config.S3TvfOptions;
+import org.apache.doris.spark.config.S3TvfOptionsTest;
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.Arrays;
+
+public class S3TvfSqlBuilderTest {
+
+ @Test
+ public void buildsIamRoleAndGzipProperties() throws Exception {
+ S3TvfOptions options =
S3TvfOptions.fromConfig(S3TvfOptionsTest.configWithRole());
+ S3TvfCommittable committable =
+ new S3TvfCommittable(
+ "db",
+ "tbl",
+ "label",
+ Arrays.asList("spark/file.json.gz"),
+ Arrays.asList("id"));
+
+ String sql = new S3TvfSqlBuilder(options).buildInsertSql(committable);
+
+ Assert.assertTrue(sql.contains("'s3.role_arn' =
'arn:aws:iam::123456789012:role/doris'"));
+ Assert.assertTrue(sql.contains("'s3.external_id' = 'external-id'"));
+ Assert.assertTrue(sql.contains("'compress_type' = 'gz'"));
+ Assert.assertFalse(sql.contains("s3.access_key"));
+ Assert.assertFalse(sql.contains("s3.secret_key"));
+ }
+}
diff --git
a/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/config/S3TvfOptionsTest.java
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/config/S3TvfOptionsTest.java
new file mode 100644
index 0000000..f77c2cd
--- /dev/null
+++
b/spark-doris-connector/spark-doris-connector-base/src/test/java/org/apache/doris/spark/config/S3TvfOptionsTest.java
@@ -0,0 +1,86 @@
+// 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.doris.spark.config;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import java.util.HashMap;
+import java.util.Map;
+
+public class S3TvfOptionsTest {
+
+ @Test
+ public void supportsIamRoleAndDefaultGzip() throws Exception {
+ S3TvfOptions options = S3TvfOptions.fromConfig(configWithRole());
+
+ Assert.assertEquals("arn:aws:iam::123456789012:role/doris",
options.getRoleArn());
+ Assert.assertEquals("external-id", options.getExternalId());
+ Assert.assertTrue(options.isGzipCompressionEnabled());
+ Assert.assertFalse(options.hasStaticCredentials());
+ }
+
+ @Test
+ public void allowsStaticSourceCredentialsAndDisablingGzip() throws
Exception {
+ Map<String, String> values = baseOptions();
+ values.put("doris.sink.s3.access-key", "access-key");
+ values.put("doris.sink.s3.secret-key", "secret-key");
+ values.put("doris.sink.s3.role-arn",
"arn:aws:iam::123456789012:role/doris");
+ values.put("doris.sink.properties.compress_type", "");
+
+ S3TvfOptions options =
S3TvfOptions.fromConfig(DorisConfig.fromMap(values, false));
+
+ Assert.assertTrue(options.hasStaticCredentials());
+ Assert.assertFalse(options.isGzipCompressionEnabled());
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void rejectsMissingCredentialsAndRole() throws Exception {
+ S3TvfOptions.fromConfig(DorisConfig.fromMap(baseOptions(), false));
+ }
+
+ @Test(expected = IllegalArgumentException.class)
+ public void rejectsUnsupportedCompression() throws Exception {
+ Map<String, String> values = baseOptions();
+ values.put("doris.sink.s3.role-arn",
"arn:aws:iam::123456789012:role/doris");
+ values.put("doris.sink.properties.compress_type", "zstd");
+ S3TvfOptions.fromConfig(DorisConfig.fromMap(values, false));
+ }
+
+ public static DorisConfig configWithRole() throws Exception {
+ Map<String, String> values = baseOptions();
+ values.put("doris.sink.s3.role-arn",
"arn:aws:iam::123456789012:role/doris");
+ values.put("doris.sink.s3.external-id", "external-id");
+ return DorisConfig.fromMap(values, false);
+ }
+
+ private static Map<String, String> baseOptions() {
+ Map<String, String> values = new HashMap<>();
+ values.put("doris.fenodes", "localhost:8030");
+ values.put("doris.query.port", "9030");
+ values.put("doris.table.identifier", "db.tbl");
+ values.put("doris.user", "root");
+ values.put("doris.password", "");
+ values.put("doris.sink.mode", "tvf");
+ values.put("doris.sink.s3.endpoint", "https://s3.example.com");
+ values.put("doris.sink.s3.region", "us-east-1");
+ values.put("doris.sink.s3.bucket", "staging");
+ values.put("doris.sink.s3.prefix", "spark/orders");
+ return values;
+ }
+}
diff --git
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfIamRoleITCase.scala
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfIamRoleITCase.scala
new file mode 100644
index 0000000..c780018
--- /dev/null
+++
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfIamRoleITCase.scala
@@ -0,0 +1,150 @@
+// 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.doris.spark.sql
+
+import org.apache.doris.spark.container.ContainerUtils
+import org.apache.doris.spark.container.instance.DorisCustomerContainer
+import org.apache.spark.sql.{SaveMode, SparkSession}
+import org.junit.Assert.assertEquals
+import org.junit.{AfterClass, Assume, BeforeClass, Test}
+import org.slf4j.LoggerFactory
+
+import java.sql.Connection
+import java.util
+import java.util.UUID
+import scala.collection.JavaConverters._
+
+/** Opt-in integration test for Spark DataFrame S3 TVF writes with an AWS IAM
role. */
+class S3TvfIamRoleITCase {
+ import S3TvfIamRoleITCase._
+
+ @Test
+ def testWritesThroughIamRole(): Unit = {
+ val table = "iam_role_" + UUID.randomUUID().toString.replace("-", "")
+ try {
+ executeSql(
+ s"CREATE DATABASE IF NOT EXISTS `$database`",
+ s"CREATE TABLE `$database`.`$table` (`id` INT, `name` VARCHAR(64)) " +
+ "DUPLICATE KEY(`id`) DISTRIBUTED BY HASH(`id`) BUCKETS 1 " +
+ "PROPERTIES (\"replication_num\" = \"1\")")
+
+ val session = SparkSession.builder()
+ .appName("s3-tvf-iam-role-it")
+ .master("local[1]")
+ .config("spark.ui.enabled", "false")
+ .config("spark.driver.host", "127.0.0.1")
+ .config("spark.driver.bindAddress", "127.0.0.1")
+ .getOrCreate()
+ try {
+ import session.implicits._
+ Seq((1, "doris"), (2, "spark"))
+ .toDF("id", "name")
+ .write
+ .format("doris")
+ .options(sinkOptions(table).asScala)
+ .mode(SaveMode.Append)
+ .save()
+ } finally {
+ session.stop()
+ }
+
+ val connection = doris.getQueryConnection
+ val rows = try {
+ ContainerUtils.executeSQLStatement(
+ connection,
+ LOG,
+ s"SELECT id,name FROM `$database`.`$table` ORDER BY id",
+ 2)
+ } finally {
+ connection.close()
+ }
+ assertEquals(util.Arrays.asList("1,doris", "2,spark"), rows)
+ } finally {
+ executeSql(s"DROP TABLE IF EXISTS `$database`.`$table`")
+ }
+ }
+
+ private def sinkOptions(table: String): util.Map[String, String] = {
+ val options = new util.HashMap[String, String]()
+ options.put("doris.fenodes", doris.getFenodes)
+ options.put("doris.query.port", doris.getQueryPort.toString)
+ options.put("doris.table.identifier", s"$database.$table")
+ options.put("user", doris.getUsername)
+ options.put("password", doris.getPassword)
+ options.put("doris.sink.mode", "tvf")
+ options.put("doris.sink.label.prefix", "iam_role_" + UUID.randomUUID())
+ options.put("doris.sink.s3.endpoint", requiredProperty("s3_endpoint"))
+ options.put("doris.sink.s3.region", requiredProperty("s3_region"))
+ options.put("doris.sink.s3.bucket", requiredProperty("s3_bucket"))
+ options.put("doris.sink.s3.prefix", System.getProperty("s3_prefix",
"doris-spark-connector-it"))
+ options.put("doris.sink.s3.role-arn", requiredProperty("s3_role_arn"))
+ optionalProperty("s3_external_id")
+ .foreach(options.put("doris.sink.s3.external-id", _))
+ options
+ }
+
+ private def executeSql(sql: String*): Unit = {
+ val connection: Connection = doris.getQueryConnection
+ try {
+ ContainerUtils.executeSQLStatement(connection, LOG, sql: _*)
+ } finally {
+ connection.close()
+ }
+ }
+
+ private def requiredProperty(name: String): String = {
+ optionalProperty(name).getOrElse(
+ throw new IllegalArgumentException("Missing required system property: "
+ name))
+ }
+
+ private def optionalProperty(name: String): Option[String] = {
+ Option(System.getProperty(name)).map(_.trim).filter(_.nonEmpty)
+ }
+}
+
+object S3TvfIamRoleITCase {
+ private val LOG = LoggerFactory.getLogger(classOf[S3TvfIamRoleITCase])
+ private val database = "test_s3_tvf_iam_role"
+ private var doris: DorisCustomerContainer = _
+
+ @BeforeClass
+ def useExternalEnvironment(): Unit = {
+ Assume.assumeTrue(
+ "IAM role ITCase requires -Ds3_tvf_iam_role_it=true",
+ java.lang.Boolean.getBoolean("s3_tvf_iam_role_it"))
+ Assume.assumeTrue(
+ "IAM role ITCase requires -Dcustomer_env=true",
+ java.lang.Boolean.getBoolean("customer_env"))
+ Seq("s3_endpoint", "s3_region", "s3_bucket", "s3_role_arn")
+ .foreach(name => {
+ val value = System.getProperty(name)
+ if (value == null || value.trim.isEmpty) {
+ throw new IllegalArgumentException("Missing required system
property: " + name)
+ }
+ })
+ doris = new DorisCustomerContainer()
+ doris.startContainer()
+ }
+
+ @AfterClass
+ def closeExternalEnvironment(): Unit = {
+ if (doris != null) {
+ doris.close()
+ }
+ }
+}
diff --git
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfSinkITCase.scala
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfSinkITCase.scala
index 0f5176e..360df0c 100644
---
a/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfSinkITCase.scala
+++
b/spark-doris-connector/spark-doris-connector-it/src/test/java/org/apache/doris/spark/sql/S3TvfSinkITCase.scala
@@ -80,11 +80,43 @@ class S3TvfSinkITCase extends AbstractS3TvfTestBase {
assertEquals(2, keys.size)
val keyPattern =
Pattern.quote(prefix + labelPrefix + "_" + table + "_") +
- "[0-9a-f-]{36}_0_[0-9]+\\.json"
+ "[0-9a-f-]{36}_0_[0-9]+\\.json\\.gz"
assertTrue(keys.forall(_.matches(keyPattern)))
assertFalse(keys.exists(_.contains("//")))
}
+ @Test
+ def testDisablesCompressionWithEmptyCompressType(): Unit = {
+ val table = uniqueName("uncompressed")
+ val prefix = uniqueName("uncompressed_objects")
+ val labelPrefix = uniqueName("uncompressed_label")
+ createDuplicateTable(table, "`id` INT, `name` VARCHAR(128)")
+
+ withSpark("local[1]") { session =>
+ import session.implicits._
+ val options = s3TvfSinkOptions(
+ s"$database.$table", prefix, labelPrefix, 100)
+ options.put("doris.sink.properties.compress_type", "")
+ Seq((1, "doris"))
+ .toDF("id", "name")
+ .write
+ .format("doris")
+ .options(options.asScala)
+ .mode(SaveMode.Append)
+ .save()
+ }
+
+ assertResult(
+ table,
+ "id,name",
+ util.Collections.singletonList("1,doris"),
+ columnCount = 2)
+ val keys = listObjectKeys(prefix + "/").asScala
+ assertEquals(1, keys.size)
+ assertTrue(keys.head.endsWith(".json"))
+ assertFalse(keys.head.endsWith(".json.gz"))
+ }
+
@Test
def testParallelTaskWrite(): Unit = {
val table = uniqueName("parallel")
@@ -111,7 +143,7 @@ class S3TvfSinkITCase extends AbstractS3TvfTestBase {
assertEquals(2, keys.size)
val keyPattern =
(Pattern.quote(prefix + "/" + labelPrefix + "_" + table + "_") +
- "([0-9a-f-]{36})_([0-9]+)_0\\.json").r
+ "([0-9a-f-]{36})_([0-9]+)_0\\.json\\.gz").r
val taskFiles = keys.map {
case keyPattern(uuid, partition) => uuid -> partition
case key => throw new AssertionError("Unexpected S3 TVF object key: " +
key)
@@ -247,7 +279,7 @@ class S3TvfSinkITCase extends AbstractS3TvfTestBase {
val keys = listObjectKeys(prefix + "/").asScala
val keyPattern =
(Pattern.quote(prefix + "/" + labelPrefix + "_" + table + "_") +
- "([0-9a-f-]{36})_[0-9]+_[0-9]+\\.json").r
+ "([0-9a-f-]{36})_[0-9]+_[0-9]+\\.json\\.gz").r
val uuids = keys.map {
case keyPattern(uuid) => uuid
case key => throw new AssertionError("Unexpected S3 TVF object key: " +
key)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]