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 88f22fd875e [refactor](outfile) Inline file name formatting and add
regression coverage (#69189)
88f22fd875e is described below
commit 88f22fd875e0be4dea2c58c53744f4074378a870
Author: Gabriel <[email protected]>
AuthorDate: Fri Oct 16 23:31:58 2026 +0800
[refactor](outfile) Inline file name formatting and add regression coverage
(#69189)
### What problem does this PR solve?
Follow-up to #68746. Remove `result_file_name.h` and inline its single
formatting expression at both call sites: the OUTFILE and TVF writers.
File naming behavior is unchanged.
Replace the helper-only unit test with an end-to-end regression covering
both writers, CSV/Parquet/ORC, default and explicit padding widths,
invalid values, custom OUTFILE suffixes, and rollover beyond index 9.
The test checks actual object names, verifies minimum-width semantics
and lexical ordering, reads the exported data back, and cleans up its
unique object prefix.
### Validation
- The new `test_result_file_name_padding` regression passed locally
against a master build containing #68746. A mutation that forces padding
to zero fails the filename assertion.
- Both inlined C++ formatting expressions passed the five original
boundary scenarios in a standalone harness.
- clang-format 16 and `git diff --check` passed.
- The modified full BE binary has not been rebuilt locally; the new
regression's local run used the existing master binary. Full build and
regression validation will run in CI.
### Release note
None; no behavior change.
### Check List (For Author)
- [x] Regression test
- [x] No behavior change
- [x] No documentation change needed
---
be/src/exec/sink/writer/result_file_name.h | 39 --------
be/src/exec/sink/writer/vfile_result_writer.cpp | 8 +-
be/src/exec/sink/writer/vtvf_table_writer.cpp | 6 +-
be/test/exec/sink/writer/result_file_name_test.cpp | 73 --------------
.../export_p0/test_result_file_name_padding.groovy | 111 +++++++++++++++++++++
5 files changed, 119 insertions(+), 118 deletions(-)
diff --git a/be/src/exec/sink/writer/result_file_name.h
b/be/src/exec/sink/writer/result_file_name.h
deleted file mode 100644
index 111061bb48e..00000000000
--- a/be/src/exec/sink/writer/result_file_name.h
+++ /dev/null
@@ -1,39 +0,0 @@
-// 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 4528eb92360..9ccf2f2dcbf 100644
--- a/be/src/exec/sink/writer/vfile_result_writer.cpp
+++ b/be/src/exec/sink/writer/vfile_result_writer.cpp
@@ -38,7 +38,6 @@
#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"
@@ -163,8 +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;
- *file_name = build_result_file_name(_file_opts->file_path,
print_id(_fragment_instance_id),
- _file_idx++,
_file_opts->file_name_padding, suffix);
+ // 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 137279c8c32..70765f51d81 100644
--- a/be/src/exec/sink/writer/vtvf_table_writer.cpp
+++ b/be/src/exec/sink/writer/vtvf_table_writer.cpp
@@ -21,7 +21,6 @@
#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"
@@ -166,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 =
- build_result_file_name(_file_path, query_id_str, _file_idx++,
_file_name_padding, ext);
+ // 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/test/exec/sink/writer/result_file_name_test.cpp
b/be/test/exec/sink/writer/result_file_name_test.cpp
deleted file mode 100644
index 9bb533edf64..00000000000
--- a/be/test/exec/sink/writer/result_file_name_test.cpp
+++ /dev/null
@@ -1,73 +0,0 @@
-// 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/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]