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

Gabriel39 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 374d87d81de [perf](csv reader) Reduce V2 CSV parsing overhead (#69875)
374d87d81de is described below

commit 374d87d81de557a8b002cb94a5816b7d8d650c12
Author: hui lai <[email protected]>
AuthorDate: Fri Oct 30 20:02:17 2026 +0800

    [perf](csv reader) Reduce V2 CSV parsing overhead (#69875)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #69716
    
    Problem Summary:
    
    After restoring sequential prefetch, large V2 CSV loads still spend
    substantial CPU time in row parsing and string materialization. The
    reader compares a general separator at every byte, updates atomic
    profile counters per cell, starts three stage timers per row, and
    repeats checked string-column casts per cell. It also ignores the row
    cap supplied by TableReader.
    
    This change combines four optimizations on master:
    - Compare the separator byte directly for plain single-character
    separators; retain enclosed, multi-character, Hive/OpenCSV and PROTO
    parsing paths.
    - Accumulate exact work counters locally and publish at
    get_block/count/schema return, including errors. Sample stage timings
    every 1024 physical read_line calls, starting with the first, and expose
    unscaled SampledReadLineTime, SampledSplitLineTime and
    SampledDeserializeTime with sample counts. DelimitedTextReader remains
    full elapsed time; split-prefix alignment has its own full timer.
    - Validate nullable string column types and reserve offsets/null maps
    once per mutable batch, then insert strings directly with the existing
    NULL, quoting and escape semantics.
    - Honor FileReader::set_batch_size, retaining the existing byte budget
    and runtime default when no explicit cap is supplied. The caller still
    controls the maximum batch size; this does not force 16352-row blocks.
    
    Shared counters and batch sizing also apply to the Hive text reader.
    
    ### Performance benchmark
    
    On TPC-DS SF100, elapsed time decreased from **1,944 seconds to 1,375
    seconds**:
    
    | Benchmark | Before | After | Time reduction | Speedup |
    | --- | ---: | ---: | ---: | ---: |
    | TPC-DS SF100 | 1,944 s | 1,375 s | 29.3% (569 s) | 1.41× |
    
    ### Release note
    
    Reduce V2 CSV parsing overhead and honor caller-provided batch sizes.
    Delimited-text per-stage profile timers are now explicitly named
    Sampled* and contain unscaled sampled durations; use the full
    DelimitedTextReader timer for elapsed-time comparisons.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test: added cases for dynamic/zero batch caps, byte limits,
    filtered blocks, cross-batch sampling and exact counters,
    single/multi-character separators, NULL and escape behavior, missing
    fields, reordered projection and copy-on-write. Extended error,
    rejected-row and COUNT counter coverage.
    - Execution: run-be-ut.sh was attempted with CsvV2ReaderTest.*,
    TextV2ReaderTest.* and LegacyAndV2/PlainCsvSplitTest.*. It exits before
    C++ compilation because the local JDK 17 fails to initialize (Failed
    setting boot class path), including outside the sandbox. Full build/UT
    remain pending. Performance benchmark results are reported above.
    - Passed: repository clang-format 16 formatting/check scripts,
    build-support/check-build-hygiene.sh and git diff --check.
    - Behavior changed: Yes. Existing batch-size contract is honored, and
    detailed stage timers are explicitly sampled. CSV value semantics are
    unchanged.
    - Does this need documentation: No. The profile includes a timing
    explanation; no new configuration or SQL syntax.
    
    For further benchmark comparisons, use identical hardware, input,
    compression, cache conditions and load batch/byte-budget settings for
    the parent and this commit. Compare total load time, Scanner CPU time,
    DelimitedTextReader time and RowsRead/BlockNumber. Do not directly
    compare Sampled* timers with the previous full stage timers.
---
 be/src/format_v2/delimited_text/csv_reader.cpp     |  47 ++++--
 be/src/format_v2/delimited_text/csv_reader.h       |   1 +
 .../delimited_text/delimited_text_reader.cpp       |  85 ++++++++--
 .../delimited_text/delimited_text_reader.h         |  26 ++++
 .../format_v2/delimited_text/csv_reader_test.cpp   | 171 ++++++++++++++++++++-
 .../format_v2/delimited_text/text_reader_test.cpp  |  32 +++-
 6 files changed, 328 insertions(+), 34 deletions(-)

diff --git a/be/src/format_v2/delimited_text/csv_reader.cpp 
b/be/src/format_v2/delimited_text/csv_reader.cpp
index 0d3ebf2cfd5..9a28707fd4e 100644
--- a/be/src/format_v2/delimited_text/csv_reader.cpp
+++ b/be/src/format_v2/delimited_text/csv_reader.cpp
@@ -23,6 +23,7 @@
 
 #include "core/assert_cast.h"
 #include "core/column/column_nullable.h"
+#include "core/column/column_string.h"
 #include "core/data_type/data_type_string.h"
 #include "core/data_type_serde/data_type_string_serde.h"
 #include "format/file_reader/new_plain_binary_line_reader.h"
@@ -221,6 +222,14 @@ void CsvReader::_split_line(const Slice& line) {
             append_value(value_start, separator_position - value_start);
             value_start = separator_position + _value_separator.size();
         }
+    } else if (_value_separator.size() == 1) {
+        const char separator = _value_separator[0];
+        for (size_t i = 0; i < line.size; ++i) {
+            if (line.data[i] == separator) {
+                append_value(value_start, i - value_start);
+                value_start = i + 1;
+            }
+        }
     } else {
         for (size_t i = 0; i < line.size;) {
             if (starts_with_at(line, i, _value_separator)) {
@@ -236,6 +245,18 @@ void CsvReader::_split_line(const Slice& line) {
     append_value(value_start, line.size - value_start);
 }
 
+void CsvReader::_prepare_columns(std::vector<MutableColumnPtr>& columns, 
size_t batch_size) {
+    for (const auto& column : _requested_columns) {
+        if (!column.nullable_string_fast_path) {
+            continue;
+        }
+        auto& nullable = 
assert_cast<ColumnNullable&>(*columns[column.block_position.value()]);
+        auto& strings = 
assert_cast<ColumnString&>(nullable.get_nested_column());
+        strings.get_offsets().reserve(strings.size() + batch_size);
+        nullable.get_null_map_data().reserve(nullable.size() + batch_size);
+    }
+}
+
 Status CsvReader::_deserialize_one_cell(const RequestedColumn& column, 
IColumn* output,
                                         Slice value) {
     DORIS_CHECK(output != nullptr);
@@ -243,18 +264,20 @@ Status CsvReader::_deserialize_one_cell(const 
RequestedColumn& column, IColumn*
         return _append_null(output);
     }
     if (column.nullable_string_fast_path) {
-        auto& null_column = assert_cast<ColumnNullable&>(*output);
-        // String is the hottest CSV type. Avoid the generic nullable serde 
wrapper here:
-        // deserialize directly into the nested string column and append the 
null map bit ourselves.
+        // _prepare_columns validates the concrete types once per batch, after 
copy-on-write.
+        auto& null_column = assert_cast<ColumnNullable&, 
TypeCheckOnRelease::DISABLE>(*output);
+        auto& string_column = assert_cast<ColumnString&, 
TypeCheckOnRelease::DISABLE>(
+                null_column.get_nested_column());
         if (_hive_csv_parser) {
             // Splitting and decoding are one operation for OpenCSV; a second 
CSV serde pass
             // would unescape literal data and confuse empty strings with 
missing fields.
-            null_column.get_nested_column().insert_data(value.data, 
value.size);
+            string_column.insert_data(value.data, value.size);
             null_column.get_null_map_data().push_back(0);
             return Status::OK();
         }
         if (_empty_field_as_null && value.size == 0) {
-            null_column.insert_data(nullptr, 0);
+            string_column.insert_default();
+            null_column.get_null_map_data().push_back(1);
             return Status::OK();
         }
         // CSV keeps empty-field handling separate from null_format matching. 
An empty
@@ -263,16 +286,16 @@ Status CsvReader::_deserialize_one_cell(const 
RequestedColumn& column, IColumn*
         const bool quoted = _options.converted_from_string && 
value.trim_double_quotes();
         if (!quoted && _options.null_len > 0 && value.size == 
_options.null_len &&
             std::memcmp(value.data, _options.null_format, value.size) == 0) {
-            null_column.insert_data(nullptr, 0);
+            string_column.insert_default();
+            null_column.get_null_map_data().push_back(1);
             return Status::OK();
         }
-        static DataTypeStringSerDe string_serde(TYPE_STRING);
-        auto status = 
string_serde.deserialize_one_cell_from_csv(null_column.get_nested_column(),
-                                                                 value, 
_options);
-        if (!status.ok()) {
-            null_column.insert_data(nullptr, 0);
-            return Status::OK();
+        // Keep the same unescaping as StringSerDe, which always succeeds for 
CSV strings.
+        if (_options.escape_char != 0) {
+            escape_string_for_csv(value.data, &value.size, 
_options.escape_char,
+                                  _options.quote_char);
         }
+        string_column.insert_data(value.data, value.size);
         null_column.get_null_map_data().push_back(0);
         return Status::OK();
     }
diff --git a/be/src/format_v2/delimited_text/csv_reader.h 
b/be/src/format_v2/delimited_text/csv_reader.h
index c8565e6792a..a54ed13ff66 100644
--- a/be/src/format_v2/delimited_text/csv_reader.h
+++ b/be/src/format_v2/delimited_text/csv_reader.h
@@ -60,6 +60,7 @@ private:
     Status _create_line_reader() override;
     Status _validate_line(const Slice& line) override;
     void _split_line(const Slice& line) override;
+    void _prepare_columns(std::vector<MutableColumnPtr>& columns, size_t 
batch_size) override;
     Status _deserialize_one_cell(const RequestedColumn& column, IColumn* 
output,
                                  Slice value) override;
     Slice _normalize_value(Slice value) const override;
diff --git a/be/src/format_v2/delimited_text/delimited_text_reader.cpp 
b/be/src/format_v2/delimited_text/delimited_text_reader.cpp
index 7aa9dff1edf..42357257ddb 100644
--- a/be/src/format_v2/delimited_text/delimited_text_reader.cpp
+++ b/be/src/format_v2/delimited_text/delimited_text_reader.cpp
@@ -43,6 +43,7 @@
 #include "runtime/file_scan_profile.h"
 #include "runtime/runtime_state.h"
 #include "util/decompressor.h"
+#include "util/defer_op.h"
 #include "util/string_util.h"
 #include "util/utf8_check.h"
 
@@ -192,11 +193,21 @@ void DelimitedTextReader::_init_profile() {
     _text_profile.create_line_reader_time =
             ADD_CHILD_TIMER_WITH_LEVEL(_profile, "CreateLineReaderTime", 
DELIMITED_TEXT_PROFILE, 1);
     _text_profile.read_line_time =
-            ADD_CHILD_TIMER_WITH_LEVEL(_profile, "ReadLineTime", 
DELIMITED_TEXT_PROFILE, 1);
+            ADD_CHILD_TIMER_WITH_LEVEL(_profile, "SampledReadLineTime", 
DELIMITED_TEXT_PROFILE, 1);
     _text_profile.split_line_time =
-            ADD_CHILD_TIMER_WITH_LEVEL(_profile, "SplitLineTime", 
DELIMITED_TEXT_PROFILE, 1);
-    _text_profile.deserialize_time =
-            ADD_CHILD_TIMER_WITH_LEVEL(_profile, "DeserializeTime", 
DELIMITED_TEXT_PROFILE, 1);
+            ADD_CHILD_TIMER_WITH_LEVEL(_profile, "SampledSplitLineTime", 
DELIMITED_TEXT_PROFILE, 1);
+    _text_profile.deserialize_time = ADD_CHILD_TIMER_WITH_LEVEL(_profile, 
"SampledDeserializeTime",
+                                                                
DELIMITED_TEXT_PROFILE, 1);
+    _text_profile.split_prefix_time =
+            ADD_CHILD_TIMER_WITH_LEVEL(_profile, "SplitPrefixTime", 
DELIMITED_TEXT_PROFILE, 1);
+    _text_profile.sampled_read_line_calls = ADD_CHILD_COUNTER_WITH_LEVEL(
+            _profile, "SampledReadLineCalls", TUnit::UNIT, 
DELIMITED_TEXT_PROFILE, 1);
+    _text_profile.sampled_parsed_rows = ADD_CHILD_COUNTER_WITH_LEVEL(
+            _profile, "SampledParsedRows", TUnit::UNIT, 
DELIMITED_TEXT_PROFILE, 1);
+    _profile->add_info_string(
+            "DelimitedTextTiming",
+            "Stage timers are unscaled samples of every 1024th read_line call "
+            "(starting with the first); DelimitedTextReader is full elapsed 
time");
     _text_profile.conjunct_filter_time =
             ADD_CHILD_TIMER_WITH_LEVEL(_profile, "ConjunctFilterTime", 
DELIMITED_TEXT_PROFILE, 1);
     _text_profile.delete_conjunct_filter_time = ADD_CHILD_TIMER_WITH_LEVEL(
@@ -285,6 +296,7 @@ Status DelimitedTextReader::init(RuntimeState* state) {
 
 Status DelimitedTextReader::infer_schema(RuntimeState* state,
                                          std::vector<ColumnDefinition>* 
schema) {
+    Defer profile_flush {[this] { _flush_profile(); }};
     _schema_only = true;
     RETURN_IF_ERROR(init(state));
     if (_file_description->range_start_offset != 0) {
@@ -360,8 +372,13 @@ Status 
DelimitedTextReader::open(std::shared_ptr<FileScanRequest> request) {
     return Status::OK();
 }
 
+void DelimitedTextReader::set_batch_size(size_t batch_size) {
+    _batch_size = std::max(size_t {1}, batch_size);
+}
+
 Status DelimitedTextReader::get_block(Block* file_block, size_t* rows, bool* 
eof) {
     SCOPED_TIMER(_text_profile.total_time);
+    Defer profile_flush {[this] { _flush_profile(); }};
     DORIS_CHECK(file_block != nullptr);
     DORIS_CHECK(rows != nullptr);
     DORIS_CHECK(eof != nullptr);
@@ -369,7 +386,9 @@ Status DelimitedTextReader::get_block(Block* file_block, 
size_t* rows, bool* eof
         return Status::InternalError("{} v2 reader is not open", _reader_name);
     }
 
-    const auto batch_size = _runtime_state != nullptr ? 
_runtime_state->batch_size() : 4096;
+    const size_t batch_size =
+            _batch_size != 0 ? _batch_size
+                             : (_runtime_state != nullptr ? 
_runtime_state->batch_size() : 4096);
     const auto max_block_bytes = _runtime_state != nullptr
                                          ? 
_runtime_state->preferred_block_size_bytes()
                                          : std::numeric_limits<size_t>::max();
@@ -379,6 +398,7 @@ Status DelimitedTextReader::get_block(Block* file_block, 
size_t* rows, bool* eof
     {
         auto columns_guard = file_block->mutate_columns_scoped();
         auto& columns = columns_guard.mutable_columns();
+        _prepare_columns(columns, batch_size);
         // Delimited text readers are column-pruned but not lazy materialized: 
all file-local
         // columns requested by TableReader are decoded before file-local 
conjuncts are evaluated.
         while (*rows < batch_size && !_line_reader_eof &&
@@ -437,6 +457,7 @@ Status DelimitedTextReader::get_block(Block* file_block, 
size_t* rows, bool* eof
 Status DelimitedTextReader::get_aggregate_result(const FileAggregateRequest& 
request,
                                                  FileAggregateResult* result) {
     SCOPED_TIMER(_text_profile.total_time);
+    Defer profile_flush {[this] { _flush_profile(); }};
     DORIS_CHECK(result != nullptr);
     if (request.agg_type != TPushAggOp::type::COUNT) {
         return Status::NotSupported("{} v2 reader only supports COUNT 
aggregate pushdown",
@@ -461,7 +482,7 @@ Status DelimitedTextReader::get_aggregate_result(const 
FileAggregateRequest& req
             break;
         }
         if (line.size == 0) {
-            update_counter(_text_profile.empty_lines_read, 1);
+            ++_pending_profile.empty_lines_read;
             if (_empty_line_as_record() ||
                 (_runtime_state != nullptr && 
_runtime_state->is_read_csv_empty_line_as_null())) {
                 ++count;
@@ -638,7 +659,7 @@ Status DelimitedTextReader::_read_next_line(Slice* line, 
bool* eof) {
     DORIS_CHECK(line != nullptr);
     DORIS_CHECK(eof != nullptr);
     if (_align_split_prefix) {
-        SCOPED_TIMER(_text_profile.read_line_time);
+        SCOPED_TIMER(_text_profile.split_prefix_time);
         DCHECK_EQ(_skip_lines, 1);
         size_t skipped_lines = 0;
         auto* text_reader = 
assert_cast<NewPlainTextLineReader*>(_line_reader.get());
@@ -648,13 +669,19 @@ Status DelimitedTextReader::_read_next_line(Slice* line, 
bool* eof) {
         _align_split_prefix = false;
         _skip_lines = 0;
         _bom_removed = true;
-        update_counter(_text_profile.skipped_lines, skipped_lines);
+        _pending_profile.skipped_lines += skipped_lines;
     }
     while (true) {
         const uint8_t* ptr = nullptr;
         size_t size = 0;
         {
-            SCOPED_TIMER(_text_profile.read_line_time);
+            _sample_current_line =
+                    _profile != nullptr && (_read_line_calls++ % 
PROFILE_SAMPLE_INTERVAL == 0);
+            std::optional<ScopedRawTimer<MonotonicStopWatch, int64_t>> timer;
+            if (_sample_current_line) {
+                ++_pending_profile.sampled_read_line_calls;
+                timer.emplace(&_pending_profile.read_line_time);
+            }
             RETURN_IF_ERROR(_line_reader->read_line(&ptr, &size, 
&_line_reader_eof, _io_ctx.get()));
         }
         if (_line_reader_eof && size == 0) {
@@ -670,12 +697,12 @@ Status DelimitedTextReader::_read_next_line(Slice* line, 
bool* eof) {
         if (_skip_lines > 0) {
             --_skip_lines;
             _bom_removed = true;
-            update_counter(_text_profile.skipped_lines, 1);
+            ++_pending_profile.skipped_lines;
             continue;
         }
         *line = Slice(ptr, size);
         *eof = false;
-        update_counter(_text_profile.raw_lines_read, 1);
+        ++_pending_profile.raw_lines_read;
         return Status::OK();
     }
 }
@@ -685,12 +712,12 @@ Status DelimitedTextReader::_fill_columns_from_line(const 
Slice& line,
                                                     size_t* rows) {
     DORIS_CHECK(columns != nullptr);
     if (line.size == 0) {
-        update_counter(_text_profile.empty_lines_read, 1);
+        ++_pending_profile.empty_lines_read;
         if (!_empty_line_as_record()) {
             if (_runtime_state != nullptr && 
_runtime_state->is_read_csv_empty_line_as_null()) {
                 for (const auto& column : _requested_columns) {
                     
RETURN_IF_ERROR(_append_null((*columns)[column.block_position.value()].get()));
-                    update_counter(_text_profile.cells_deserialized, 1);
+                    ++_pending_profile.cells_deserialized;
                 }
                 ++(*rows);
             }
@@ -700,10 +727,17 @@ Status DelimitedTextReader::_fill_columns_from_line(const 
Slice& line,
     RETURN_IF_ERROR(_validate_line(line));
 
     {
-        SCOPED_TIMER(_text_profile.split_line_time);
+        std::optional<ScopedRawTimer<MonotonicStopWatch, int64_t>> timer;
+        if (_sample_current_line) {
+            ++_pending_profile.sampled_parsed_rows;
+            timer.emplace(&_pending_profile.split_line_time);
+        }
         _split_line(line);
     }
-    SCOPED_TIMER(_text_profile.deserialize_time);
+    std::optional<ScopedRawTimer<MonotonicStopWatch, int64_t>> timer;
+    if (_sample_current_line) {
+        timer.emplace(&_pending_profile.deserialize_time);
+    }
     if (_request->reject_invalid_rows) {
         const auto expected = 
cast_set<size_t>(_scan_params->num_of_columns_from_file);
         const bool ignore_extra = 
_scan_params->file_attributes.ignore_csv_redundant_col;
@@ -722,12 +756,31 @@ Status DelimitedTextReader::_fill_columns_from_line(const 
Slice& line,
                               ? _split_values[field_index]
                               : Slice(_options.null_format, _options.null_len);
         RETURN_IF_ERROR(_deserialize_one_cell(column, output, 
_normalize_value(value)));
-        update_counter(_text_profile.cells_deserialized, 1);
+        ++_pending_profile.cells_deserialized;
     }
     ++(*rows);
     return Status::OK();
 }
 
+void DelimitedTextReader::_flush_profile() {
+    update_counter(_text_profile.raw_lines_read, 
_pending_profile.raw_lines_read);
+    update_counter(_text_profile.empty_lines_read, 
_pending_profile.empty_lines_read);
+    update_counter(_text_profile.skipped_lines, 
_pending_profile.skipped_lines);
+    update_counter(_text_profile.cells_deserialized, 
_pending_profile.cells_deserialized);
+    update_counter(_text_profile.read_line_time, 
_pending_profile.read_line_time);
+    update_counter(_text_profile.split_line_time, 
_pending_profile.split_line_time);
+    update_counter(_text_profile.deserialize_time, 
_pending_profile.deserialize_time);
+    update_counter(_text_profile.sampled_read_line_calls, 
_pending_profile.sampled_read_line_calls);
+    update_counter(_text_profile.sampled_parsed_rows, 
_pending_profile.sampled_parsed_rows);
+    _pending_profile = {};
+}
+
+void DelimitedTextReader::_prepare_columns(std::vector<MutableColumnPtr>& 
columns,
+                                           size_t batch_size) {
+    (void)columns;
+    (void)batch_size;
+}
+
 Status DelimitedTextReader::_validate_line(const Slice& line) {
     (void)line;
     return Status::OK();
diff --git a/be/src/format_v2/delimited_text/delimited_text_reader.h 
b/be/src/format_v2/delimited_text/delimited_text_reader.h
index 645ba26eecd..4b7fc1f4014 100644
--- a/be/src/format_v2/delimited_text/delimited_text_reader.h
+++ b/be/src/format_v2/delimited_text/delimited_text_reader.h
@@ -55,6 +55,7 @@ public:
             TableColumnMapperOptions options) const override;
     Status open(std::shared_ptr<FileScanRequest> request) override;
     Status get_block(Block* file_block, size_t* rows, bool* eof) override;
+    void set_batch_size(size_t batch_size) override;
     Status get_aggregate_result(const FileAggregateRequest& request,
                                 FileAggregateResult* result) override;
     Status close() override;
@@ -64,6 +65,9 @@ protected:
         RuntimeProfile::Counter* total_time = nullptr;
         RuntimeProfile::Counter* open_file_time = nullptr;
         RuntimeProfile::Counter* create_line_reader_time = nullptr;
+        RuntimeProfile::Counter* split_prefix_time = nullptr;
+        RuntimeProfile::Counter* sampled_read_line_calls = nullptr;
+        RuntimeProfile::Counter* sampled_parsed_rows = nullptr;
         RuntimeProfile::Counter* read_line_time = nullptr;
         RuntimeProfile::Counter* split_line_time = nullptr;
         RuntimeProfile::Counter* deserialize_time = nullptr;
@@ -112,6 +116,8 @@ protected:
     // Split one logical line into `_split_values`. The common materialization 
path then resolves
     // requested field ordinals against `_split_values`.
     virtual void _split_line(const Slice& line) = 0;
+    // Prepare mutable output columns once per batch, after Block has resolved 
copy-on-write.
+    virtual void _prepare_columns(std::vector<MutableColumnPtr>& columns, 
size_t batch_size);
     // Deserialize a single normalized field into the requested output column 
using the
     // format-specific serde API.
     virtual Status _deserialize_one_cell(const RequestedColumn& column, 
IColumn* output,
@@ -178,6 +184,26 @@ private:
     Status _fill_columns_from_line(const Slice& line, 
std::vector<MutableColumnPtr>* columns,
                                    size_t* rows);
 
+    void _flush_profile();
+
+    // Publish exact counts once per API call instead of updating atomic 
counters per cell.
+    struct PendingProfile {
+        int64_t raw_lines_read = 0;
+        int64_t empty_lines_read = 0;
+        int64_t skipped_lines = 0;
+        int64_t cells_deserialized = 0;
+        int64_t read_line_time = 0;
+        int64_t split_line_time = 0;
+        int64_t deserialize_time = 0;
+        int64_t sampled_read_line_calls = 0;
+        int64_t sampled_parsed_rows = 0;
+    } _pending_profile;
+    // Keep sampling periodic across batches, including skipped headers and 
EOF probes.
+    static constexpr size_t PROFILE_SAMPLE_INTERVAL = 1024;
+    uint64_t _read_line_calls = 0;
+    bool _sample_current_line = false;
+    // Zero means use the runtime default until TableReader supplies its 
predicted row cap.
+    size_t _batch_size = 0;
     std::string _reader_name;
 };
 
diff --git a/be/test/format_v2/delimited_text/csv_reader_test.cpp 
b/be/test/format_v2/delimited_text/csv_reader_test.cpp
index 8493b216e06..6c9682ad151 100644
--- a/be/test/format_v2/delimited_text/csv_reader_test.cpp
+++ b/be/test/format_v2/delimited_text/csv_reader_test.cpp
@@ -752,9 +752,9 @@ TEST_F(CsvV2ReaderTest, 
ProfileCountersTrackReadParseDeserializeAndFilter) {
 
     EXPECT_NE(_profile.get_counter("OpenFileTime"), nullptr);
     EXPECT_NE(_profile.get_counter("CreateLineReaderTime"), nullptr);
-    EXPECT_NE(_profile.get_counter("ReadLineTime"), nullptr);
-    EXPECT_NE(_profile.get_counter("SplitLineTime"), nullptr);
-    EXPECT_NE(_profile.get_counter("DeserializeTime"), nullptr);
+    EXPECT_NE(_profile.get_counter("SampledReadLineTime"), nullptr);
+    EXPECT_NE(_profile.get_counter("SampledSplitLineTime"), nullptr);
+    EXPECT_NE(_profile.get_counter("SampledDeserializeTime"), nullptr);
     EXPECT_NE(_profile.get_counter("ConjunctFilterTime"), nullptr);
     EXPECT_NE(_profile.get_counter("DeleteConjunctFilterTime"), nullptr);
     EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), 3);
@@ -769,6 +769,162 @@ TEST_F(CsvV2ReaderTest, 
ProfileCountersTrackReadParseDeserializeAndFilter) {
     EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), 6);
 }
 
+// A row cap can change between calls. It must override the runtime default 
without dropping
+// records, and a zero prediction must still make progress.
+TEST_F(CsvV2ReaderTest, HonorsChangingBatchSizeAndZeroPrediction) {
+    std::ofstream output(_file_path, std::ios::binary);
+    output << "id,name,score\n";
+    for (int i = 1; i <= 7; ++i) {
+        output << i << ",name,10\n";
+    }
+    output.close();
+    _state._batch_size = 2;
+    auto reader = create_reader(_file_path, &_params, _slots, &_state, 
&_profile);
+    reader->set_batch_size(1);
+    auto request = std::make_shared<FileScanRequest>();
+    request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+    request->local_positions.emplace(LocalColumnId(1), LocalIndex(1));
+    ASSERT_TRUE(reader->open(request).ok());
+    std::vector<ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    int next_id = 1;
+    const std::vector<size_t> caps {1, 3, 0, 2, 2};
+    const std::vector<size_t> expected {1, 3, 1, 2, 0};
+    for (size_t i = 0; i < caps.size(); ++i) {
+        reader->set_batch_size(caps[i]);
+        auto block = make_block(schema, {0, 1});
+        size_t rows = 0;
+        bool eof = false;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        ASSERT_EQ(rows, expected[i]);
+        EXPECT_EQ(eof, rows == 0);
+        for (size_t row = 0; row < rows; ++row) {
+            EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, row), 
next_id++);
+            EXPECT_EQ(nullable_string_at(*block.get_by_position(1).column, 
row), "name");
+        }
+        EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), (next_id - 1) 
* 2);
+        EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), next_id - 1);
+    }
+    EXPECT_EQ(next_id, 8);
+    EXPECT_EQ(counter_value(&_profile, "SkippedLines"), 1);
+}
+
+TEST_F(CsvV2ReaderTest, BatchPredictionStillHonorsByteBudget) {
+    const bool saved = config::enable_adaptive_batch_size;
+    Defer restore {[&] { config::enable_adaptive_batch_size = saved; }};
+    config::enable_adaptive_batch_size = true;
+    _state._query_options.__set_preferred_block_size_bytes(1);
+    auto reader = create_reader(_file_path, &_params, _slots, &_state, 
&_profile);
+    reader->set_batch_size(100);
+    auto request = std::make_shared<FileScanRequest>();
+    request->local_positions.emplace(LocalColumnId(1), LocalIndex(0));
+    ASSERT_TRUE(reader->open(request).ok());
+    std::vector<ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    for (const std::string expected : {"alice", "bob"}) {
+        auto block = make_block(schema, {1});
+        size_t rows = 0;
+        bool eof = false;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        ASSERT_EQ(rows, 1);
+        EXPECT_EQ(nullable_string_at(*block.get_by_position(0).column, 0), 
expected);
+    }
+}
+
+// Sampling spans batch boundaries; exact work counts are published before 
every return.
+TEST_F(CsvV2ReaderTest, ProfileSamplingSpansBatchesWithExactCounts) {
+    std::ofstream output(_file_path, std::ios::binary);
+    for (int i = 0; i < 2050; ++i) {
+        output << "1,name,10\n";
+    }
+    output.close();
+    _params.file_attributes.__set_header_type("");
+    auto reader = create_reader(_file_path, &_params, _slots, &_state, 
&_profile);
+    reader->set_batch_size(300);
+    auto request = std::make_shared<FileScanRequest>();
+    request->local_positions.emplace(LocalColumnId(1), LocalIndex(0));
+    ASSERT_TRUE(reader->open(request).ok());
+    std::vector<ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    size_t total = 0;
+    bool eof = false;
+    while (!eof) {
+        auto block = make_block(schema, {1});
+        size_t rows = 0;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        total += rows;
+        EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), total);
+        EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), total);
+    }
+    EXPECT_EQ(total, 2050);
+    EXPECT_EQ(counter_value(&_profile, "SampledReadLineCalls"), 3);
+    EXPECT_EQ(counter_value(&_profile, "SampledParsedRows"), 3);
+}
+
+// The single-byte split and direct string insertion must preserve NULL, 
quoted literals,
+// escaping, trailing empty fields, missing fields and reordered projections 
across batches.
+TEST_F(CsvV2ReaderTest, 
StringFastPathPreservesValuesForSingleAndMultiByteSeparators) {
+    auto type = make_nullable(std::make_shared<DataTypeString>());
+    std::vector<SlotDescriptor*> slots;
+    for (int i = 0; i < 3; ++i) {
+        slots.push_back(make_test_slot(&_pool, i, i, type, "s" + 
std::to_string(i)));
+    }
+    for (const std::string separator : {"|", "||"}) {
+        for (bool empty_as_null : {false, true}) {
+            SCOPED_TRACE(testing::Message() << separator << ", empty_as_null=" 
<< empty_as_null);
+            std::ofstream output(_file_path, std::ios::binary);
+            output << "NULL" << separator << "\"NULL\"" << separator << "\n";
+            output << "left" << separator << "a\\b" << separator << "tail\n";
+            output << "only\n";
+            output.close();
+            auto params = _params;
+            params.file_attributes.__set_header_type("");
+            params.file_attributes.__set_trim_double_quotes(true);
+            
params.file_attributes.text_params.__set_column_separator(separator);
+            params.file_attributes.text_params.__set_null_format("NULL");
+            
params.file_attributes.text_params.__set_empty_field_as_null(empty_as_null);
+            params.file_attributes.text_params.__set_escape('\\');
+            auto reader = create_reader(_file_path, &params, slots, &_state, 
&_profile);
+            reader->set_batch_size(1);
+            auto request = std::make_shared<FileScanRequest>();
+            request->local_positions.emplace(LocalColumnId(2), LocalIndex(0));
+            request->local_positions.emplace(LocalColumnId(0), LocalIndex(1));
+            request->local_positions.emplace(LocalColumnId(1), LocalIndex(2));
+            ASSERT_TRUE(reader->open(request).ok());
+            std::vector<ColumnDefinition> schema;
+            ASSERT_TRUE(reader->get_schema(&schema).ok());
+            for (int row = 0; row < 3; ++row) {
+                auto block = make_block(schema, {2, 0, 1});
+                // Hold shared columns while the reader acquires its mutable 
batch.
+                auto shared = block.get_by_position(0).column;
+                size_t rows = 0;
+                bool eof = false;
+                ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+                ASSERT_EQ(rows, 1);
+                EXPECT_EQ(shared->size(), 0);
+                const auto& c0 = *block.get_by_position(0).column;
+                const auto& c1 = *block.get_by_position(1).column;
+                const auto& c2 = *block.get_by_position(2).column;
+                if (row == 0) {
+                    EXPECT_EQ(is_null_at(c0, 0), empty_as_null);
+                    EXPECT_EQ(nullable_string_at(c0, 0), "");
+                    EXPECT_TRUE(is_null_at(c1, 0));
+                    EXPECT_FALSE(is_null_at(c2, 0));
+                    EXPECT_EQ(nullable_string_at(c2, 0), "NULL");
+                } else if (row == 1) {
+                    EXPECT_EQ(nullable_string_at(c0, 0), "tail");
+                    EXPECT_EQ(nullable_string_at(c1, 0), "left");
+                    EXPECT_EQ(nullable_string_at(c2, 0), "ab");
+                } else {
+                    EXPECT_TRUE(is_null_at(c0, 0));
+                    EXPECT_EQ(nullable_string_at(c1, 0), "only");
+                    EXPECT_TRUE(is_null_at(c2, 0));
+                }
+            }
+        }
+    }
+}
+
 // Scenario: CSV has no embedded nested schema, but TableColumnMapper still 
needs semantic children
 // for complex table columns. The reader synthesizes ARRAY/MAP/STRUCT children 
from the slot type
 // while keeping the top-level local id as the CSV field ordinal from 
column_idxs.
@@ -828,6 +984,8 @@ TEST_F(CsvV2ReaderTest, 
LoadRejectsWrongFieldCountsWithoutShiftingFollowingRows)
     EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, 0), 1);
     EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, 1), 4);
     EXPECT_EQ(io_ctx->load_filtered_rows, 2);
+    EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), 4);
+    EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), 6);
 }
 
 // Scenario: CSV v2 honors FileScanRequest local positions, so TableReader can 
request a subset of
@@ -880,6 +1038,9 @@ TEST_F(CsvV2ReaderTest, 
InvalidUtf8FailsWhenValidationEnabled) {
     bool eof = false;
     const auto status = reader->get_block(&block, &rows, &eof);
     EXPECT_FALSE(status.ok());
+    EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), 1);
+    EXPECT_EQ(counter_value(&_profile, "SkippedLines"), 1);
+    EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), 0);
     EXPECT_TRUE(status.to_string().find("Only support csv data in utf8 codec") 
!= std::string::npos)
             << status;
 }
@@ -1103,6 +1264,10 @@ TEST_F(CsvV2ReaderTest, CountAggregateScansRows) {
     FileAggregateResult aggregate_result;
     ASSERT_TRUE(reader->get_aggregate_result(aggregate_request, 
&aggregate_result).ok());
     EXPECT_EQ(aggregate_result.count, 2);
+    EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), 2);
+    EXPECT_EQ(counter_value(&_profile, "SkippedLines"), 1);
+    EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), 0);
+    EXPECT_EQ(counter_value(&_profile, "SampledParsedRows"), 0);
 }
 
 TEST_F(CsvV2ReaderTest, CountNullableColumnFallsBackToRowMaterialization) {
diff --git a/be/test/format_v2/delimited_text/text_reader_test.cpp 
b/be/test/format_v2/delimited_text/text_reader_test.cpp
index 4927ee7622e..2fe1aa84bef 100644
--- a/be/test/format_v2/delimited_text/text_reader_test.cpp
+++ b/be/test/format_v2/delimited_text/text_reader_test.cpp
@@ -408,9 +408,9 @@ TEST_F(TextV2ReaderTest, 
ProfileCountersTrackReadParseDeserializeAndFilter) {
 
     EXPECT_NE(_profile.get_counter("OpenFileTime"), nullptr);
     EXPECT_NE(_profile.get_counter("CreateLineReaderTime"), nullptr);
-    EXPECT_NE(_profile.get_counter("ReadLineTime"), nullptr);
-    EXPECT_NE(_profile.get_counter("SplitLineTime"), nullptr);
-    EXPECT_NE(_profile.get_counter("DeserializeTime"), nullptr);
+    EXPECT_NE(_profile.get_counter("SampledReadLineTime"), nullptr);
+    EXPECT_NE(_profile.get_counter("SampledSplitLineTime"), nullptr);
+    EXPECT_NE(_profile.get_counter("SampledDeserializeTime"), nullptr);
     EXPECT_NE(_profile.get_counter("ConjunctFilterTime"), nullptr);
     EXPECT_NE(_profile.get_counter("DeleteConjunctFilterTime"), nullptr);
     EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), 3);
@@ -424,6 +424,32 @@ TEST_F(TextV2ReaderTest, 
ProfileCountersTrackReadParseDeserializeAndFilter) {
     EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), 6);
 }
 
+TEST_F(TextV2ReaderTest, HonorsBatchSizeAcrossEmptyAndFilteredBlocks) {
+    auto reader = create_reader(_file_path, &_params, _slots, &_state, 
&_profile);
+    reader->set_batch_size(1);
+    auto request = std::make_shared<FileScanRequest>();
+    request->local_positions.emplace(LocalColumnId(0), LocalIndex(0));
+    request->conjuncts = {
+            prepared_conjunct(&_state, 
std::make_shared<NullableIntGreaterThanExpr>(0, 1))};
+    ASSERT_TRUE(reader->open(request).ok());
+    std::vector<ColumnDefinition> schema;
+    ASSERT_TRUE(reader->get_schema(&schema).ok());
+    for (int batch = 0; batch < 3; ++batch) {
+        auto block = make_block(schema, {0});
+        size_t rows = 0;
+        bool eof = false;
+        ASSERT_TRUE(reader->get_block(&block, &rows, &eof).ok());
+        EXPECT_EQ(rows, batch == 1 ? 1 : 0);
+        EXPECT_EQ(eof, batch == 2);
+        if (rows == 1) {
+            EXPECT_EQ(nullable_int_at(*block.get_by_position(0).column, 0), 2);
+        }
+    }
+    EXPECT_EQ(counter_value(&_profile, "RawLinesRead"), 2);
+    EXPECT_EQ(counter_value(&_profile, "CellsDeserialized"), 2);
+    EXPECT_EQ(counter_value(&_profile, "DelimitedRowsFilteredByConjunct"), 1);
+}
+
 TEST_F(TextV2ReaderTest, 
FormatProfilesKeepDistinctCountersInBothInitializationOrders) {
     auto expect_format_children = [](RuntimeProfile* profile) {
         TRuntimeProfileTree tree;


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to