This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 4ffe6632b8c [feature](outfile) add opt-in file_name_padding (#68746)
4ffe6632b8c is described below
commit 4ffe6632b8cf75a0d647719c5f7a5f77308f0e5f
Author: Alvaro Tuso <[email protected]>
AuthorDate: Thu Oct 15 05:04:08 2026 -0300
[feature](outfile) add opt-in file_name_padding (#68746)
### What problem does this PR solve?
Files generated by `SELECT INTO OUTFILE` and `TVF` have a suffix that is
not lexicographically sorted, which makes listing files from object
storage awkward for queries with `ORDER BY`, as they will come back out
of order.
This PR adds a new backwards compatible parameter called
`file_name_padding` that allows padding file names with `0`s in a
similar fashion to what spark and trino do. The value is configurable
from `0` (no padding, same behavior as today) to `10` (max int width)
Files with the default behavior will sort like this:
```
_9
_10
_100
_11
```
While files with appropriate padding will sort like this, allowing the
file names to be ordered in the same manner as the query results:
```
_00009
_00010
_00011
...
_00099
_00100
```
Issue Number: close #xxx
Related PR: #xxx
Problem Summary:
### Release note
None
### Check List (For Author)
- Test <!-- At least one of them must be included. -->
- [ ] Regression test
- [ ] Unit Test
- [ ] Manual test (add detailed scripts or steps below)
- [ ] No need to test or manual test. Explain why:
- [ ] This is a refactor/code format and no logic has been changed.
- [ ] Previous test can cover this change.
- [ ] No code files have been changed.
- [ ] Other reason <!-- Add your reason? -->
- Behavior changed:
- [ ] No.
- [ ] Yes. <!-- Explain the behavior change -->
- Does this need documentation?
- [ ] No.
- [ ] Yes. <!-- Add document PR link here. eg:
https://github.com/apache/doris-website/pull/1214 -->
### Check List (For Reviewer who merge this PR)
- [ ] Confirm the release note
- [ ] Confirm test cases
- [ ] Confirm document
- [ ] Add branch pick label <!-- Add branch pick label that this PR
should merge into -->
Co-authored-by: Alvaro Tuso <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
---
be/src/exec/operator/result_sink_operator.h | 6 ++
be/src/exec/sink/writer/result_file_name.h | 39 ++++++++++++
be/src/exec/sink/writer/vfile_result_writer.cpp | 7 +--
be/src/exec/sink/writer/vtvf_table_writer.cpp | 6 +-
be/src/exec/sink/writer/vtvf_table_writer.h | 1 +
be/test/exec/sink/writer/result_file_name_test.cpp | 73 ++++++++++++++++++++++
.../org/apache/doris/analysis/OutFileClause.java | 20 ++++++
.../org/apache/doris/planner/TVFTableSink.java | 21 +++++++
gensrc/thrift/DataSinks.thrift | 9 +++
9 files changed, 176 insertions(+), 6 deletions(-)
diff --git a/be/src/exec/operator/result_sink_operator.h
b/be/src/exec/operator/result_sink_operator.h
index 51be25c02a6..bada2ed61c0 100644
--- a/be/src/exec/operator/result_sink_operator.h
+++ b/be/src/exec/operator/result_sink_operator.h
@@ -65,6 +65,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;
@@ -78,6 +81,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/result_file_name.h
b/be/src/exec/sink/writer/result_file_name.h
new file mode 100644
index 00000000000..111061bb48e
--- /dev/null
+++ b/be/src/exec/sink/writer/result_file_name.h
@@ -0,0 +1,39 @@
+// 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.
+
+#pragma once
+
+#include <fmt/format.h>
+
+#include <string>
+
+namespace doris {
+
+// Builds an output file name of the form "{prefix}{id}_{idx}.{suffix}",
shared by
+// the SELECT INTO OUTFILE writer (VFileResultWriter) and the INSERT INTO
FUNCTION
+// TVF writer (VTVFTableWriter).
+//
+// When pad > 0 the index is zero-padded to `pad` digits so that the files
produced
+// by a single writer sort lexicographically (e.g. _00011 sorts after _00002
rather
+// than before _2). pad <= 0 leaves the index unpadded, which is byte-for-byte
the
+// historical name format.
+inline std::string build_result_file_name(const std::string& prefix, const
std::string& id, int idx,
+ int pad, const std::string& suffix) {
+ return fmt::format("{}{}_{:0{}}.{}", prefix, id, idx, pad < 0 ? 0 : pad,
suffix);
+}
+
+} // namespace doris
diff --git a/be/src/exec/sink/writer/vfile_result_writer.cpp
b/be/src/exec/sink/writer/vfile_result_writer.cpp
index ce9e73d2eba..4528eb92360 100644
--- a/be/src/exec/sink/writer/vfile_result_writer.cpp
+++ b/be/src/exec/sink/writer/vfile_result_writer.cpp
@@ -38,6 +38,7 @@
#include "core/value/decimalv2_value.h"
#include "core/value/large_int_value.h"
#include "exec/operator/result_sink_operator.h"
+#include "exec/sink/writer/result_file_name.h"
#include "exec/sink/writer/vmysql_result_writer.h"
#include "exprs/function/cast/cast_to_string.h"
#include "exprs/vexpr.h"
@@ -162,10 +163,8 @@ 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();
+ *file_name = build_result_file_name(_file_opts->file_path,
print_id(_fragment_instance_id),
+ _file_idx++,
_file_opts->file_name_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..137279c8c32 100644
--- a/be/src/exec/sink/writer/vtvf_table_writer.cpp
+++ b/be/src/exec/sink/writer/vtvf_table_writer.cpp
@@ -21,6 +21,7 @@
#include "common/status.h"
#include "core/block/block.h"
+#include "exec/sink/writer/result_file_name.h"
#include "exprs/vexpr.h"
#include "exprs/vexpr_context.h"
#include "io/file_factory.h"
@@ -48,6 +49,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 +166,8 @@ 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++;
+ *file_name =
+ build_result_file_name(_file_path, query_id_str, _file_idx++,
_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/be/test/exec/sink/writer/result_file_name_test.cpp
b/be/test/exec/sink/writer/result_file_name_test.cpp
new file mode 100644
index 00000000000..9bb533edf64
--- /dev/null
+++ b/be/test/exec/sink/writer/result_file_name_test.cpp
@@ -0,0 +1,73 @@
+// 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.
+
+#include "exec/sink/writer/result_file_name.h"
+
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <string>
+#include <vector>
+
+namespace doris {
+
+TEST(ResultFileNameTest, no_padding_preserves_legacy_format) {
+ EXPECT_EQ("s3://bucket/exp_abc_0.csv",
+ build_result_file_name("s3://bucket/exp_", "abc", 0, 0, "csv"));
+ EXPECT_EQ("s3://bucket/exp_abc_11.csv",
+ build_result_file_name("s3://bucket/exp_", "abc", 11, 0, "csv"));
+}
+
+TEST(ResultFileNameTest, negative_padding_is_treated_as_none) {
+ EXPECT_EQ("s3://bucket/exp_abc_7.csv",
+ build_result_file_name("s3://bucket/exp_", "abc", 7, -1, "csv"));
+}
+
+TEST(ResultFileNameTest, padding_zero_fills_to_width) {
+ EXPECT_EQ("s3://bucket/exp_abc_00000.csv",
+ build_result_file_name("s3://bucket/exp_", "abc", 0, 5, "csv"));
+ EXPECT_EQ("s3://bucket/exp_abc_00011.csv",
+ build_result_file_name("s3://bucket/exp_", "abc", 11, 5, "csv"));
+}
+
+TEST(ResultFileNameTest, padding_does_not_truncate_when_index_exceeds_width) {
+ // Width is a minimum, never a cap: an index wider than `pad` is emitted
in full.
+ EXPECT_EQ("s3://bucket/exp_abc_123456.csv",
+ build_result_file_name("s3://bucket/exp_", "abc", 123456, 5,
"csv"));
+}
+
+TEST(ResultFileNameTest, padded_names_sort_lexicographically) {
+ // The bug this feature fixes: without padding, _11 sorts before _2.
+ std::vector<std::string> unpadded;
+ std::vector<std::string> padded;
+ for (int i : {0, 1, 2, 10, 11, 100}) {
+ unpadded.push_back(build_result_file_name("exp_", "id", i, 0, "csv"));
+ padded.push_back(build_result_file_name("exp_", "id", i, 5, "csv"));
+ }
+
+ std::vector<std::string> unpadded_sorted = unpadded;
+ std::sort(unpadded_sorted.begin(), unpadded_sorted.end());
+ // Lexicographic sort scrambles the numeric order for unpadded names.
+ EXPECT_NE(unpadded, unpadded_sorted);
+
+ std::vector<std::string> padded_sorted = padded;
+ std::sort(padded_sorted.begin(), padded_sorted.end());
+ // Padded names keep numeric order under a lexicographic sort.
+ EXPECT_EQ(padded, padded_sorted);
+}
+
+} // namespace doris
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 64311e202c7..68ef656f1d1 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
@@ -118,11 +118,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;
@@ -131,6 +135,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.
@@ -553,6 +558,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);
@@ -758,6 +777,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 52189daca14..507e1c6595c 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
@@ -48,6 +48,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;
@@ -156,6 +161,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 4ac527df0ef..6ad86202e07 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 {
@@ -618,6 +624,9 @@ struct TTVFTableSink {
// Java class name: a plugin's classes are private to its own classloader,
so BE addresses a
// writer by the plugin directory it is deployed in and the factory name
within it.
15: optional string writer_class
+ // 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 {
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]