This is an automated email from the ASF dual-hosted git repository.

corgy-w pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/seatunnel.git


The following commit(s) were added to refs/heads/dev by this push:
     new 72f32edde0 [Fix][Connector-V2][File] Respect custom filename for 
binary sink (#10817)
72f32edde0 is described below

commit 72f32edde0a12c9dcffcca317b2f3ce728483eac
Author: Jast <[email protected]>
AuthorDate: Fri May 8 10:36:38 2026 +0800

    [Fix][Connector-V2][File] Respect custom filename for binary sink (#10817)
---
 docs/en/connectors/sink/LocalFile.md               |   2 +
 docs/zh/connectors/sink/LocalFile.md               |   4 +-
 .../seatunnel/file/config/BaseFileSinkConfig.java  |   2 +
 .../seatunnel/file/sink/BaseFileSinkWriter.java    |  23 +++
 .../file/sink/writer/BinaryWriteStrategy.java      |  31 ++++
 .../file/local/LocalFileBinarySinkTest.java        | 193 +++++++++++++++++++++
 6 files changed, 254 insertions(+), 1 deletion(-)

diff --git a/docs/en/connectors/sink/LocalFile.md 
b/docs/en/connectors/sink/LocalFile.md
index a22fd057e1..115159756b 100644
--- a/docs/en/connectors/sink/LocalFile.md
+++ b/docs/en/connectors/sink/LocalFile.md
@@ -94,6 +94,8 @@ Only used when `custom_filename` is `true`
 `file_name_expression` describes the file expression which will be created 
into the `path`. We can add the variable `${now}` or `${uuid}` in the 
`file_name_expression`, like `test_${uuid}_${now}`,
 `${now}` represents the current time, and its format can be defined by 
specifying the option `filename_time_format`.
 
+For `file_format_type = "binary"`, `custom_filename = true` is intended for 
renaming a single source file in each sink task. When multiple source files are 
written by the same sink task, keep `custom_filename = false` to preserve 
source relative paths. When parallel sink subtasks are used, include 
`${transactionId}` or `${uuid}` in `file_name_expression` to avoid multiple 
subtasks writing the same final file.
+
 Please note that, If `is_enable_transaction` is `true`, we will auto add 
`${transactionId}_` in the head of the file.
 
 ### filename_time_format [string]
diff --git a/docs/zh/connectors/sink/LocalFile.md 
b/docs/zh/connectors/sink/LocalFile.md
index 648999e659..0e1549a751 100644
--- a/docs/zh/connectors/sink/LocalFile.md
+++ b/docs/zh/connectors/sink/LocalFile.md
@@ -91,6 +91,8 @@ import ChangeLog from '../changelog/connector-file-local.md';
 
 `file_name_expression` 描述将创建到 `path` 中的文件表达式。我们可以在 `file_name_expression` 
中添加变量 `${now}` 或 `${uuid}`,例如 `test_${uuid}_${now}`,`${now}` 表示当前时间,其格式可以通过指定 
`filename_time_format` 选项来定义。
 
+对于 `file_format_type = "binary"`,`custom_filename = true` 适用于在每个 sink task 
中重命名单个源文件。当同一个 sink task 会写入多个源文件时,请保持 `custom_filename = 
false`,以保留源文件相对路径。当使用并行 sink subtask 时,请在 `file_name_expression` 中包含 
`${transactionId}` 或 `${uuid}`,避免多个 subtask 写入同一个最终文件。
+
 请注意,如果 `is_enable_transaction` 为 `true`,我们将自动在文件名的头部添加 `${transactionId}_`。
 
 ### filename_time_format [string]
@@ -340,4 +342,4 @@ LocalFile {
 
 ## 变更日志
 
-<ChangeLog />
\ No newline at end of file
+<ChangeLog />
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/config/BaseFileSinkConfig.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/config/BaseFileSinkConfig.java
index b5f12771db..488cf83a89 100644
--- 
a/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/config/BaseFileSinkConfig.java
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/config/BaseFileSinkConfig.java
@@ -40,6 +40,7 @@ public class BaseFileSinkConfig implements DelimiterConfig, 
Serializable {
     protected int batchSize;
     protected String path;
     protected String fileNameExpression;
+    protected boolean customFilename;
     protected boolean singleFileMode;
     protected boolean createEmptyFileWhenNoData;
     protected FileFormat fileFormat;
@@ -60,6 +61,7 @@ public class BaseFileSinkConfig implements DelimiterConfig, 
Serializable {
             this.path = "";
         }
         this.fileNameExpression = 
pluginConfig.get(FileBaseSinkOptions.FILE_NAME_EXPRESSION);
+        this.customFilename = 
pluginConfig.get(FileBaseSinkOptions.CUSTOM_FILENAME);
         this.singleFileMode = 
pluginConfig.get(FileBaseSinkOptions.SINGLE_FILE_MODE);
         this.createEmptyFileWhenNoData =
                 
pluginConfig.get(FileBaseSinkOptions.CREATE_EMPTY_FILE_WHEN_NO_DATA);
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/BaseFileSinkWriter.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/BaseFileSinkWriter.java
index c880026cc3..6849205009 100644
--- 
a/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/BaseFileSinkWriter.java
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/BaseFileSinkWriter.java
@@ -22,9 +22,11 @@ import 
org.apache.seatunnel.shade.org.apache.commons.lang3.StringUtils;
 import org.apache.seatunnel.api.sink.SinkWriter;
 import org.apache.seatunnel.api.sink.SupportMultiTableSinkWriter;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.Constants;
 import org.apache.seatunnel.common.exception.CommonError;
 import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
 import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
+import org.apache.seatunnel.connectors.seatunnel.file.config.FileFormat;
 import org.apache.seatunnel.connectors.seatunnel.file.config.HadoopConf;
 import 
org.apache.seatunnel.connectors.seatunnel.file.exception.FileConnectorException;
 import 
org.apache.seatunnel.connectors.seatunnel.file.hadoop.HadoopFileSystemProxy;
@@ -114,6 +116,21 @@ public class BaseFileSinkWriter
     }
 
     private void preCheckConfig(SinkWriter.Context context) {
+        if (writeStrategy.getFileSinkConfig().getFileFormat() == 
FileFormat.BINARY
+                && writeStrategy.getFileSinkConfig().isCustomFilename()
+                && context.getNumberOfParallelSubtasks() > 1
+                && !hasParallelUniqueFilenameExpression(
+                        
writeStrategy.getFileSinkConfig().getFileNameExpression())) {
+            throw new IllegalArgumentException(
+                    "Binary custom filename requires a unique filename 
expression when parallel "
+                            + "subtasks are used. Please include "
+                            + DEFAULT_FILE_NAME_EXPRESSION
+                            + " or ${"
+                            + Constants.UUID
+                            + "} in "
+                            + FILE_NAME_EXPRESSION.key()
+                            + ".");
+        }
         if (writeStrategy.getFileSinkConfig().isSingleFileMode()
                 && context.getNumberOfParallelSubtasks() > 1) {
             if 
(StringUtils.isNotEmpty(writeStrategy.getFileSinkConfig().getFileNameExpression())
@@ -131,6 +148,12 @@ public class BaseFileSinkWriter
         }
     }
 
+    private boolean hasParallelUniqueFilenameExpression(String 
fileNameExpression) {
+        return StringUtils.isBlank(fileNameExpression)
+                || fileNameExpression.contains(DEFAULT_FILE_NAME_EXPRESSION)
+                || fileNameExpression.contains("${" + Constants.UUID + "}");
+    }
+
     private List<String> findTransactionList(
             String jobId, String uuidPrefix, HadoopFileSystemProxy 
hadoopFileSystemProxy)
             throws IOException {
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/writer/BinaryWriteStrategy.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/writer/BinaryWriteStrategy.java
index 1fe307e33d..d1904f3c94 100644
--- 
a/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/writer/BinaryWriteStrategy.java
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-base/src/main/java/org/apache/seatunnel/connectors/seatunnel/file/sink/writer/BinaryWriteStrategy.java
@@ -21,6 +21,7 @@ import org.apache.seatunnel.api.table.catalog.CatalogTable;
 import org.apache.seatunnel.api.table.type.SeaTunnelRow;
 import org.apache.seatunnel.common.exception.CommonError;
 import org.apache.seatunnel.common.exception.CommonErrorCodeDeprecated;
+import 
org.apache.seatunnel.connectors.seatunnel.file.config.FileBaseSinkOptions;
 import 
org.apache.seatunnel.connectors.seatunnel.file.exception.FileConnectorErrorCode;
 import 
org.apache.seatunnel.connectors.seatunnel.file.exception.FileConnectorException;
 import 
org.apache.seatunnel.connectors.seatunnel.file.sink.config.FileSinkConfig;
@@ -85,6 +86,9 @@ public class BinaryWriteStrategy extends 
AbstractWriteStrategy<FSDataOutputStrea
     }
 
     public String getOrCreateFilePathBeingWritten(String relativePath) {
+        if (fileSinkConfig.isCustomFilename()) {
+            return getOrCreateCustomFilePathBeingWritten(relativePath);
+        }
         String beingWrittenFilePath = beingWrittenFile.get(relativePath);
         if (beingWrittenFilePath != null) {
             return beingWrittenFilePath;
@@ -96,6 +100,33 @@ public class BinaryWriteStrategy extends 
AbstractWriteStrategy<FSDataOutputStrea
         }
     }
 
+    private String getOrCreateCustomFilePathBeingWritten(String relativePath) {
+        String beingWrittenFilePath = beingWrittenFile.get(relativePath);
+        if (beingWrittenFilePath != null) {
+            return beingWrittenFilePath;
+        } else {
+            if (!beingWrittenFile.isEmpty()) {
+                String existingRelativePath = 
beingWrittenFile.keySet().iterator().next();
+                throw new FileConnectorException(
+                        CommonErrorCodeDeprecated.ILLEGAL_ARGUMENT,
+                        "Binary sink custom_filename only supports a single 
source file in each "
+                                + "sink task. Existing source file: "
+                                + existingRelativePath
+                                + ", new source file: "
+                                + relativePath);
+            }
+            String[] pathSegments =
+                    new String[] {
+                        transactionDirectory,
+                        FileBaseSinkOptions.NON_PARTITION,
+                        generateFileName(transactionId)
+                    };
+            String newBeingWrittenFilePath = String.join(File.separator, 
pathSegments);
+            beingWrittenFile.put(relativePath, newBeingWrittenFilePath);
+            return newBeingWrittenFilePath;
+        }
+    }
+
     @Override
     public FSDataOutputStream getOrCreateOutputStream(@NonNull String 
filePath) {
         FSDataOutputStream fsDataOutputStream = 
beingWrittenOutputStream.get(filePath);
diff --git 
a/seatunnel-connectors-v2/connector-file/connector-file-local/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/local/LocalFileBinarySinkTest.java
 
b/seatunnel-connectors-v2/connector-file/connector-file-local/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/local/LocalFileBinarySinkTest.java
new file mode 100644
index 0000000000..69662e49c2
--- /dev/null
+++ 
b/seatunnel-connectors-v2/connector-file/connector-file-local/src/test/java/org/apache/seatunnel/connectors/seatunnel/file/local/LocalFileBinarySinkTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.seatunnel.connectors.seatunnel.file.local;
+
+import org.apache.seatunnel.api.configuration.ReadonlyConfig;
+import org.apache.seatunnel.api.table.catalog.CatalogTable;
+import org.apache.seatunnel.api.table.catalog.PhysicalColumn;
+import org.apache.seatunnel.api.table.catalog.TableIdentifier;
+import org.apache.seatunnel.api.table.catalog.TableSchema;
+import org.apache.seatunnel.api.table.type.BasicType;
+import org.apache.seatunnel.api.table.type.PrimitiveByteArrayType;
+import org.apache.seatunnel.api.table.type.SeaTunnelRow;
+import org.apache.seatunnel.common.exception.SeaTunnelRuntimeException;
+import org.apache.seatunnel.common.utils.FileUtils;
+import 
org.apache.seatunnel.connectors.seatunnel.file.exception.FileConnectorException;
+import 
org.apache.seatunnel.connectors.seatunnel.file.local.sink.LocalFileSinkFactory;
+import org.apache.seatunnel.connectors.seatunnel.sink.SinkFlowTestUtils;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.DisabledOnOs;
+import org.junit.jupiter.api.condition.OS;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+@DisabledOnOs(
+        value = OS.WINDOWS,
+        disabledReason =
+                "Hadoop has windows problem, please refer 
https://cwiki.apache.org/confluence/display/HADOOP2/WindowsProblems";)
+class LocalFileBinarySinkTest {
+
+    private final CatalogTable binaryCatalogTable =
+            CatalogTable.of(
+                    TableIdentifier.of("catalog", "database", "table"),
+                    TableSchema.builder()
+                            .columns(
+                                    Arrays.asList(
+                                            PhysicalColumn.of(
+                                                    "data",
+                                                    
PrimitiveByteArrayType.INSTANCE,
+                                                    (Long) null,
+                                                    true,
+                                                    null,
+                                                    ""),
+                                            PhysicalColumn.of(
+                                                    "relativePath",
+                                                    BasicType.STRING_TYPE,
+                                                    1L,
+                                                    true,
+                                                    null,
+                                                    ""),
+                                            PhysicalColumn.of(
+                                                    "partIndex",
+                                                    BasicType.LONG_TYPE,
+                                                    1L,
+                                                    true,
+                                                    null,
+                                                    "")))
+                            .build(),
+                    Collections.emptyMap(),
+                    Collections.emptyList(),
+                    "comment");
+
+    @Test
+    void testBinarySinkUsesCustomFilename() throws Exception {
+        String testPath = 
"/tmp/seatunnel/LocalFileBinarySinkTest/customFilename";
+        FileUtils.deleteFile(testPath);
+
+        Map<String, Object> options = createBinarySinkOptions(testPath);
+        options.put("single_file_mode", true);
+        options.put("custom_filename", true);
+        options.put("file_name_expression", "expected_binary_file");
+        options.put("filename_extension", ".raw");
+
+        byte[] data = "binary-content".getBytes(StandardCharsets.UTF_8);
+        SinkFlowTestUtils.runBatchWithCheckpointDisabled(
+                binaryCatalogTable,
+                ReadonlyConfig.fromMap(options),
+                new LocalFileSinkFactory(),
+                Collections.singletonList(new SeaTunnelRow(new Object[] {data, 
"source.raw", 0L})));
+
+        Assertions.assertArrayEquals(
+                data, Files.readAllBytes(Paths.get(testPath, 
"expected_binary_file.raw")));
+        Assertions.assertFalse(FileUtils.isFileExist(Paths.get(testPath, 
"source.raw").toString()));
+    }
+
+    @Test
+    void testBinarySinkKeepsSourceRelativePathByDefault() throws Exception {
+        String testPath = 
"/tmp/seatunnel/LocalFileBinarySinkTest/defaultRelativePath";
+        FileUtils.deleteFile(testPath);
+
+        byte[] data = "binary-content".getBytes(StandardCharsets.UTF_8);
+        SinkFlowTestUtils.runBatchWithCheckpointDisabled(
+                binaryCatalogTable,
+                ReadonlyConfig.fromMap(createBinarySinkOptions(testPath)),
+                new LocalFileSinkFactory(),
+                Collections.singletonList(
+                        new SeaTunnelRow(new Object[] {data, 
"nested/source.raw", 0L})));
+
+        Assertions.assertArrayEquals(
+                data, Files.readAllBytes(Paths.get(testPath, "nested", 
"source.raw")));
+    }
+
+    @Test
+    void testBinarySinkRejectsMultipleSourceFilesWhenUsingCustomFilename() 
throws Exception {
+        String testPath = 
"/tmp/seatunnel/LocalFileBinarySinkTest/customFilenameMultipleFiles";
+        FileUtils.deleteFile(testPath);
+
+        Map<String, Object> options = createBinarySinkOptions(testPath);
+        options.put("single_file_mode", true);
+        options.put("custom_filename", true);
+        options.put("file_name_expression", "expected_binary_file");
+        options.put("filename_extension", ".raw");
+
+        byte[] data = "binary-content".getBytes(StandardCharsets.UTF_8);
+        SeaTunnelRuntimeException exception =
+                Assertions.assertThrows(
+                        SeaTunnelRuntimeException.class,
+                        () ->
+                                
SinkFlowTestUtils.runBatchWithCheckpointDisabled(
+                                        binaryCatalogTable,
+                                        ReadonlyConfig.fromMap(options),
+                                        new LocalFileSinkFactory(),
+                                        Arrays.asList(
+                                                new SeaTunnelRow(
+                                                        new Object[] {data, 
"source-1.raw", 0L}),
+                                                new SeaTunnelRow(
+                                                        new Object[] {data, 
"source-2.raw", 0L}))));
+
+        Assertions.assertInstanceOf(FileConnectorException.class, 
exception.getCause());
+        Assertions.assertTrue(
+                exception.getCause().getMessage().contains("only supports a 
single source file"));
+    }
+
+    @Test
+    void testBinarySinkRejectsUnsafeCustomFilenameWithParallelSubtasks() 
throws Exception {
+        String testPath = 
"/tmp/seatunnel/LocalFileBinarySinkTest/parallelCustomFilename";
+        FileUtils.deleteFile(testPath);
+
+        Map<String, Object> options = createBinarySinkOptions(testPath);
+        options.put("custom_filename", true);
+        options.put("file_name_expression", "expected_binary_file");
+        options.put("filename_extension", ".raw");
+
+        byte[] data = "binary-content".getBytes(StandardCharsets.UTF_8);
+        IllegalArgumentException exception =
+                Assertions.assertThrows(
+                        IllegalArgumentException.class,
+                        () ->
+                                
SinkFlowTestUtils.runParallelSubtasksBatchWithCheckpointDisabled(
+                                        binaryCatalogTable,
+                                        ReadonlyConfig.fromMap(options),
+                                        new LocalFileSinkFactory(),
+                                        Collections.singletonList(
+                                                new SeaTunnelRow(
+                                                        new Object[] {data, 
"source.raw", 0L})),
+                                        2));
+
+        Assertions.assertTrue(
+                exception
+                        .getMessage()
+                        .contains("Binary custom filename requires a unique 
filename expression"));
+    }
+
+    private Map<String, Object> createBinarySinkOptions(String path) {
+        Map<String, Object> options = new HashMap<>();
+        options.put("path", path);
+        options.put("file_format_type", "binary");
+        options.put("is_enable_transaction", false);
+        return options;
+    }
+}

Reply via email to