This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new f3789613c03 [branch-4.1](outfile) Add opt-in file_name_padding
(#68746) (#69186)
f3789613c03 is described below
commit f3789613c03a60930179a068dfae4dfedfed2876
Author: Gabriel <[email protected]>
AuthorDate: Fri Oct 16 23:29:00 2026 +0800
[branch-4.1](outfile) Add opt-in file_name_padding (#68746) (#69186)
Backport #68746 to `branch-4.1`.
Adds opt-in `file_name_padding` (0–10) to SELECT INTO OUTFILE and INSERT
INTO FUNCTION. A positive width zero-pads each writer's file index; the
default value 0 preserves existing file names. This helps rollover files
sort by index when the configured width covers the indexes.
The Thrift conflict was resolved by preserving this branch's existing
`writer_class` definition and adding only the optional padding field.
Validation:
- Both inlined C++ formatting expressions passed the five original
boundary scenarios in a focused harness.
- FE `OutFileTest` and `BindTVFTableSinkTest` passed.
- 22 local parameter checks passed across OUTFILE and TVF: defaults,
accepted widths, invalid values, and propagation into Thrift sink
options.
- FE Checkstyle and clang-format 16 checks passed for the affected
sources.
- The new SQL regression passed locally against a master build
containing #68746; branch-specific full BE build and end-to-end runs
remain for CI.
The follow-up cleanup removes `result_file_name.h`, inlines the
formatting expression in both writers, and replaces the helper-only test
with `test_result_file_name_padding`. The regression covers both writers
and all three formats, padding defaults and boundaries, invalid values,
custom OUTFILE suffixes, real file rotation past index 9, and data
read-back.
Master follow-up: #69189.
---------
Co-authored-by: Alvaro Tuso <[email protected]>
---
be/src/exec/operator/result_sink_operator.h | 6 ++
be/src/exec/sink/writer/vfile_result_writer.cpp | 9 +-
be/src/exec/sink/writer/vtvf_table_writer.cpp | 6 +-
be/src/exec/sink/writer/vtvf_table_writer.h | 1 +
.../org/apache/doris/analysis/OutFileClause.java | 20 ++++
.../org/apache/doris/planner/TVFTableSink.java | 21 ++++
gensrc/thrift/DataSinks.thrift | 9 ++
.../export_p0/test_result_file_name_padding.groovy | 111 +++++++++++++++++++++
8 files changed, 177 insertions(+), 6 deletions(-)
diff --git a/be/src/exec/operator/result_sink_operator.h
b/be/src/exec/operator/result_sink_operator.h
index 173a5b52e82..e6f249ad8d3 100644
--- a/be/src/exec/operator/result_sink_operator.h
+++ b/be/src/exec/operator/result_sink_operator.h
@@ -66,6 +66,9 @@ struct ResultFileOptions {
//Bring BOM when exporting to CSV format
bool with_bom = false;
int64_t orc_writer_version = 0;
+ // Zero-pad the per-file index in the output file name to this many digits.
+ // 0 means no padding, preserving the historical name format.
+ int file_name_padding = 0;
ResultFileOptions(const TResultFileSinkOptions& t_opt) {
file_path = t_opt.file_path;
@@ -79,6 +82,9 @@ struct ResultFileOptions {
t_opt.__isset.delete_existing_files ?
t_opt.delete_existing_files : false;
file_suffix = t_opt.file_suffix;
with_bom = t_opt.with_bom;
+ if (t_opt.__isset.file_name_padding) {
+ file_name_padding = t_opt.file_name_padding;
+ }
is_local_file = true;
if (t_opt.__isset.broker_addresses) {
diff --git a/be/src/exec/sink/writer/vfile_result_writer.cpp
b/be/src/exec/sink/writer/vfile_result_writer.cpp
index ce9e73d2eba..9ccf2f2dcbf 100644
--- a/be/src/exec/sink/writer/vfile_result_writer.cpp
+++ b/be/src/exec/sink/writer/vfile_result_writer.cpp
@@ -162,10 +162,11 @@ Status VFileResultWriter::_create_file_writer(const
std::string& file_name) {
Status VFileResultWriter::_get_next_file_name(std::string* file_name) {
std::string suffix =
_file_opts->file_suffix.empty() ? _file_format_to_name() :
_file_opts->file_suffix;
- std::stringstream ss;
- ss << _file_opts->file_path << print_id(_fragment_instance_id) << "_" <<
(_file_idx++) << "."
- << suffix;
- *file_name = ss.str();
+ // Padding is a minimum width: larger indices must retain every digit.
+ const int padding = _file_opts->file_name_padding;
+ *file_name =
+ fmt::format("{}{}_{:0{}}.{}", _file_opts->file_path,
print_id(_fragment_instance_id),
+ _file_idx++, padding < 0 ? 0 : padding, suffix);
if (_storage_type == TStorageBackendType::LOCAL) {
// For local file writer, the file_path is a local dir.
// Here we do a simple security verification by checking whether the
file exists.
diff --git a/be/src/exec/sink/writer/vtvf_table_writer.cpp
b/be/src/exec/sink/writer/vtvf_table_writer.cpp
index c4111c7c2d3..70765f51d81 100644
--- a/be/src/exec/sink/writer/vtvf_table_writer.cpp
+++ b/be/src/exec/sink/writer/vtvf_table_writer.cpp
@@ -48,6 +48,7 @@ Status VTVFTableWriter::open(RuntimeState* state,
RuntimeProfile* profile) {
_file_path = _tvf_sink.file_path;
_max_file_size_bytes =
_tvf_sink.__isset.max_file_size_bytes ?
_tvf_sink.max_file_size_bytes : 0;
+ _file_name_padding = _tvf_sink.__isset.file_name_padding ?
_tvf_sink.file_name_padding : 0;
VLOG_DEBUG << "TVF table writer open, query_id=" <<
print_id(_state->query_id())
<< ", tvf_name=" << _tvf_sink.tvf_name << ", file_path=" <<
_tvf_sink.file_path
@@ -164,8 +165,9 @@ Status VTVFTableWriter::_get_next_file_name(std::string*
file_name) {
// file_path is a prefix, generate: {prefix}{query_id}_{idx}.{ext}
std::string query_id_str = print_id(_state->query_id());
- *file_name = fmt::format("{}{}_{}.{}", _file_path, query_id_str,
_file_idx, ext);
- _file_idx++;
+ // Keep the same minimum-width index format as SELECT INTO OUTFILE.
+ *file_name = fmt::format("{}{}_{:0{}}.{}", _file_path, query_id_str,
_file_idx++,
+ _file_name_padding < 0 ? 0 : _file_name_padding,
ext);
return Status::OK();
}
diff --git a/be/src/exec/sink/writer/vtvf_table_writer.h
b/be/src/exec/sink/writer/vtvf_table_writer.h
index 54fabeecd5f..a215a21c4b5 100644
--- a/be/src/exec/sink/writer/vtvf_table_writer.h
+++ b/be/src/exec/sink/writer/vtvf_table_writer.h
@@ -72,6 +72,7 @@ private:
int64_t _current_written_bytes = 0;
int64_t _max_file_size_bytes = 0;
int _file_idx = 0;
+ int _file_name_padding = 0;
std::string _file_path;
// profile counters
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/analysis/OutFileClause.java
b/fe/fe-core/src/main/java/org/apache/doris/analysis/OutFileClause.java
index 7bca73b3bee..54b2bea6180 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/analysis/OutFileClause.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/analysis/OutFileClause.java
@@ -122,11 +122,15 @@ public class OutFileClause {
public static final String PROP_DELETE_EXISTING_FILES =
"delete_existing_files";
public static final String PROP_FILE_SUFFIX = "file_suffix";
public static final String PROP_WITH_BOM = "with_bom";
+ public static final String PROP_FILE_NAME_PADDING = "file_name_padding";
private static final String SCHEMA = "schema";
private static final long DEFAULT_MAX_FILE_SIZE_BYTES = 1 * 1024 * 1024 *
1024; // 1GB
private static final long MIN_FILE_SIZE_BYTES = 5 * 1024 * 1024L; // 5MB
+ // Upper bound on the zero-padding width for the per-file index in output
file
+ // names. _file_idx on the BE is a 32-bit int, so 10 digits covers its
full range.
+ private static final int MAX_FILE_NAME_PADDING = 10;
private String filePath;
private Map<String, String> properties;
@@ -135,6 +139,7 @@ public class OutFileClause {
private boolean deleteExistingFiles = false;
private String fileSuffix = "";
private boolean withBom = false;
+ private int fileNamePadding = 0;
private BrokerDesc brokerDesc = null;
// True if result is written to local disk.
// If set to true, the brokerDesc must be null.
@@ -559,6 +564,20 @@ public class OutFileClause {
copiedProps.remove(PROP_WITH_BOM);
}
+ if (copiedProps.containsKey(PROP_FILE_NAME_PADDING)) {
+ try {
+ fileNamePadding =
Integer.parseInt(copiedProps.get(PROP_FILE_NAME_PADDING));
+ } catch (NumberFormatException e) {
+ throw new AnalysisException(PROP_FILE_NAME_PADDING + " must be
an integer. Given: "
+ + copiedProps.get(PROP_FILE_NAME_PADDING));
+ }
+ if (fileNamePadding < 0 || fileNamePadding >
MAX_FILE_NAME_PADDING) {
+ throw new AnalysisException(PROP_FILE_NAME_PADDING + " must be
between 0 and "
+ + MAX_FILE_NAME_PADDING + ". Given: " +
fileNamePadding);
+ }
+ copiedProps.remove(PROP_FILE_NAME_PADDING);
+ }
+
if (copiedProps.containsKey(PROP_SUCCESS_FILE_NAME)) {
successFileName = copiedProps.get(PROP_SUCCESS_FILE_NAME);
FeNameFormat.checkOutfileSuccessFileName("file name",
successFileName);
@@ -763,6 +782,7 @@ public class OutFileClause {
sinkOptions.setDeleteExistingFiles(deleteExistingFiles);
sinkOptions.setFileSuffix(fileSuffix);
sinkOptions.setWithBom(withBom);
+ sinkOptions.setFileNamePadding(fileNamePadding);
if (brokerDesc != null) {
sinkOptions.setBrokerProperties(brokerDesc.getBackendConfigProperties());
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/planner/TVFTableSink.java
b/fe/fe-core/src/main/java/org/apache/doris/planner/TVFTableSink.java
index b8c184ca401..102da9af2e4 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/planner/TVFTableSink.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/planner/TVFTableSink.java
@@ -47,6 +47,11 @@ import java.util.Map;
* infrastructure as the read-side TVF (SELECT * FROM s3/hdfs/local(...)).
*/
public class TVFTableSink extends DataSink {
+ // Upper bound on the zero-padding width for the per-file index in output
file names
+ // (the "file_name_padding" property). _file_idx on the BE is a 32-bit
int, so 10 digits
+ // covers its full range.
+ private static final int MAX_FILE_NAME_PADDING = 10;
+
private final PlanNodeId exchNodeId;
private final String tvfName;
private final Map<String, String> properties;
@@ -155,6 +160,22 @@ public class TVFTableSink extends DataSink {
tSink.setMaxFileSizeBytes(Long.parseLong(maxFileSizeStr));
}
+ // Zero-pad the per-file index in output file names so they sort
lexicographically.
+ String fileNamePaddingStr = properties.get("file_name_padding");
+ if (fileNamePaddingStr != null) {
+ int fileNamePadding;
+ try {
+ fileNamePadding = Integer.parseInt(fileNamePaddingStr);
+ } catch (NumberFormatException e) {
+ throw new AnalysisException("file_name_padding must be an
integer. Given: " + fileNamePaddingStr);
+ }
+ if (fileNamePadding < 0 || fileNamePadding >
MAX_FILE_NAME_PADDING) {
+ throw new AnalysisException("file_name_padding must be between
0 and "
+ + MAX_FILE_NAME_PADDING + ". Given: " +
fileNamePadding);
+ }
+ tSink.setFileNamePadding(fileNamePadding);
+ }
+
// Delete existing files is handled by FE (InsertIntoTVFCommand),
always tell BE not to delete
tSink.setDeleteExistingFiles(false);
diff --git a/gensrc/thrift/DataSinks.thrift b/gensrc/thrift/DataSinks.thrift
index ccb3629455f..cc29b7811ee 100644
--- a/gensrc/thrift/DataSinks.thrift
+++ b/gensrc/thrift/DataSinks.thrift
@@ -160,6 +160,12 @@ struct TResultFileSinkOptions {
// currently only for csv
// TODO: merge with parquet_compression_type and orc_compression_type
22: optional PlanNodes.TFileCompressType compression_type
+
+ // Zero-pad the per-file index in the output file name to this many digits
+ // (e.g. my_prefix_{fragment_instance_id}_00000.csv). 0 or unset means no
+ // padding, preserving the historical name format. Used to make rollover
+ // files sort lexicographically (so _00011 sorts after _00002).
+ 23: optional i32 file_name_padding
}
struct TMemoryScratchSink {
@@ -601,6 +607,9 @@ struct TTVFTableSink {
13: optional i64 backend_id // local TVF: specify BE
14: optional TTVFWriterType writer_type // NATIVE or JNI
15: optional string writer_class // Java class name (required
when writer_type=JNI)
+ // Zero-pad the per-file index in the output file name to this many digits.
+ // 0 or unset means no padding. Mirrors
TResultFileSinkOptions.file_name_padding.
+ 16: optional i32 file_name_padding
}
struct TMCCommitData {
diff --git
a/regression-test/suites/export_p0/test_result_file_name_padding.groovy
b/regression-test/suites/export_p0/test_result_file_name_padding.groovy
new file mode 100644
index 00000000000..7250c9f4662
--- /dev/null
+++ b/regression-test/suites/export_p0/test_result_file_name_padding.groovy
@@ -0,0 +1,111 @@
+// 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.
+
+suite("test_result_file_name_padding", "p0,external") {
+ def bucket = context.config.otherConfigs.get("s3BucketName")
+ def prefix = "regression/result_file_name_padding/${UUID.randomUUID()}/"
+ def client = getS3Client()
+ def storage = """
+ "s3.endpoint" = "${getS3Endpoint()}",
+ "s3.region" = "${getS3Region()}",
+ "s3.access_key" = "${getS3AK()}",
+ "s3.secret_key" = "${getS3SK()}"
+ """
+ def listKeys = { String path ->
+ def keys = []
+ def listing = client.listObjects(bucket, path)
+ while (true) {
+ keys.addAll(listing.objectSummaries.collect { it.key })
+ if (!listing.isTruncated()) {
+ return keys.sort()
+ }
+ listing = client.listNextBatchOfObjects(listing)
+ }
+ }
+ def writeQuery = { String writer, String path, String format, String
extra, String query ->
+ if (writer == "outfile") {
+ return """${query} INTO OUTFILE "s3://${bucket}/${path}" FORMAT AS
${format}
+ PROPERTIES (${storage} ${extra})"""
+ } else {
+ return """INSERT INTO s3("file_path" = "s3://${bucket}/${path}",
+ "format" = "${format}", ${storage} ${extra}) ${query}"""
+ }
+ }
+ def readFiles = { String path, String format, String projection ->
+ sql """SELECT ${projection} FROM s3("uri" = "s3://${bucket}/${path}",
+ "format" = "${format}", ${storage})"""
+ }
+
+ sql "set parallel_pipeline_task_num = 1"
+ sql "set enable_parallel_outfile = false"
+ sql "set batch_size = 512"
+ try {
+ ["outfile", "tvf"].each { writer ->
+ ["csv", "parquet", "orc"].each { format ->
+ [null, 0, 1, 5, 10].each { padding ->
+ def path = "${prefix}${writer}_${format}_${padding}/part_"
+ def extra = padding == null ? "" : ",
\"file_name_padding\" = \"${padding}\""
+ sql writeQuery(writer, path, format, extra, "SELECT 7 AS
id")
+ def keys = listKeys(path)
+ assertEquals(1, keys.size())
+ def index = "0".padLeft(padding == null ? 0 : padding, '0')
+ assertTrue(keys[0].endsWith("_${index}.${format}"),
keys[0])
+ assertEquals("7", readFiles(keys[0], format,
"*")[0][0].toString())
+ }
+ }
+ ["-1", "11", "abc", "2147483648"].each { padding ->
+ test {
+ sql writeQuery(writer, "${prefix}invalid/part_", "csv",
+ ", \"file_name_padding\" = \"${padding}\"", "SELECT 7
AS id")
+ exception "file_name_padding"
+ }
+ }
+ }
+
+ def suffixPath = "${prefix}suffix/part_"
+ sql writeQuery("outfile", suffixPath, "csv",
+ ', "file_name_padding" = "5", "file_suffix" = "txt"', "SELECT 7 AS
id")
+ def suffixKeys = listKeys(suffixPath)
+ assertEquals(1, suffixKeys.size())
+ assertTrue(suffixKeys[0].endsWith("_00000.txt"))
+ assertEquals("7", readFiles(suffixKeys[0], "csv",
"*")[0][0].toString())
+
+ // Cross index 9 using real file rotation: width is a minimum, never a
truncation limit.
+ ["outfile": 1, "tvf": 5].each { writer, padding ->
+ def path = "${prefix}${writer}_rotation/part_"
+ def maxFileSize = writer == "outfile" ? "5MB" : "5242880"
+ sql writeQuery(writer, path, "csv",
+ ", \"file_name_padding\" = \"${padding}\", \"max_file_size\" =
\"${maxFileSize}\"",
+ "SELECT number, repeat('x', 4096) FROM numbers(\"number\" =
\"18000\") ORDER BY number")
+ def keys = listKeys(path)
+ assertTrue(keys.size() > 10, "Expected rotation past index 9:
${keys}")
+ def indices = keys.collect { key ->
+ def digits = key.substring(key.lastIndexOf('_') + 1,
key.lastIndexOf('.'))
+ def index = digits.toInteger()
+ assertEquals(index.toString().padLeft(padding, '0'), digits)
+ index
+ }
+ assertEquals((0..<keys.size()).toList(), indices.sort(false))
+ if (padding == 5) {
+ assertEquals(indices.sort(false), indices)
+ }
+ assertEquals("18000", readFiles("${path}*", "csv",
"count(*)")[0][0].toString())
+ }
+ } finally {
+ listKeys(prefix).each { client.deleteObject(bucket, it) }
+ }
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]