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 4bde8d0fb28 [fix](be) Preserve JSON and WAL rows with empty file 
projections (#70154)
4bde8d0fb28 is described below

commit 4bde8d0fb28452c9bb8314febbe05c4d63dde93c
Author: Gabriel <[email protected]>
AuthorDate: Wed Nov 4 14:00:35 2026 +0800

    [fix](be) Preserve JSON and WAL rows with empty file projections (#70154)
    
    ### What problem does this PR solve?
    
    Issue Number: close #69926
    
    Problem Summary:
    
    Hive JSON queries that only need partition columns can return zero rows
    even when the selected files contain records. For example, `SELECT * ...
    WHERE dt = 'p1'` returns data while `SELECT COUNT(*) ... WHERE dt =
    'p1'` returns zero. The V2 JSON reader derived its row count from the
    output block, which has no columns after physical field pruning.
    Writer-backed reader tests reproduce the same row-count loss for empty
    WAL projections.
    
    Track accepted JSON records independently of materialized columns,
    including batch progress, array expansion, error rollback, and
    ignore-malformed NULL rows. Preserve JSON-path record selection for
    empty projections, and let the existing TableReader materialize
    partition values using the returned row count.
    
    For WAL, save the deserialized source row count before materialization
    transfers column ownership, then pass it through the existing filter
    pipeline. This preserves the row count when no file columns are
    requested. The empty-projection reproduction is at the reader interface;
    production replay reachability of an empty projection remains
    unverified.
    
    Add ten JSON BE tests covering empty file schemas and projections,
    arrays, JSON roots/paths, malformed input, batch boundaries, EOF, and
    actual TableReader partition materialization. Add eight writer-backed
    WAL cases covering empty, single-column, and reordered full projections
    across multiple blocks, with and without schema discovery, plus repeated
    EOF reads. Add a WAL SQL regression that forces two original async-load
    transactions to abort, then verifies row counts, NULLs, aggregates,
    filters, and actual values after replay. Add a Hive SQL regression using
    the existing partitioned JSON fixture for COUNT, partition projections,
    grouping, and file-column filters with external COUNT pushdown enabled
    and disabled.
    
    ### Release note
    
    Fix incorrect zero counts and missing rows when querying only partition
    columns of Hive JSON tables. Preserve logical row counts for empty WAL
    reader projections.
    
    ### Check List (For Author)
    
    - Test
    - [x] Unit Test: 166 ASAN tests passed, including JSON reader,
    TableReader, WAL V2 reader, WAL reader/writer, and CSV/Text
    empty-projection controls. Before the JSON fix, nine of ten new tests
    failed; before the WAL fix, both empty-projection cases failed while six
    projection controls passed.
    - [x] WAL SQL regression: `test_group_commit_wal_row_count` passed twice
    on isolated Doris 4.1.5 with scanner V2 enabled. Generated the `.out`
    file with `run-regression-test.sh`, then reran for result comparison.
    Execution on master is pending CI.
    - [x] Hive SQL regression: added `test_hive_json_partition_only`; Groovy
    compilation passed. Full execution against the external Hive fixture is
    pending CI.
    - Behavior changed:
    - [x] Yes. Empty JSON and WAL file projections preserve logical row
    counts. Hive JSON partition-only queries produce the correct rows and
    aggregates.
    - Does this need documentation?
        - [x] No.
---
 be/src/format_v2/json/json_reader.cpp              |  53 +++++--
 be/src/format_v2/json/json_reader.h                |   3 +
 be/src/format_v2/wal/wal_reader.cpp                |   5 +-
 be/test/format_v2/json/json_reader_test.cpp        | 162 +++++++++++++++++++++
 be/test/format_v2/wal/wal_reader_test.cpp          | 120 +++++++++++++++
 .../test_group_commit_wal_row_count.out            |  17 +++
 .../hive/test_hive_json_partition_only.groovy      |  85 +++++++++++
 .../test_group_commit_wal_row_count.groovy         |  85 +++++++++++
 8 files changed, 517 insertions(+), 13 deletions(-)

diff --git a/be/src/format_v2/json/json_reader.cpp 
b/be/src/format_v2/json/json_reader.cpp
index 4052bed03d0..db8a8e93c8a 100644
--- a/be/src/format_v2/json/json_reader.cpp
+++ b/be/src/format_v2/json/json_reader.cpp
@@ -385,14 +385,14 @@ Status JsonReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
         return Status::InternalError("JSON v2 reader is not open");
     }
 
-    const auto batch_size = _runtime_state->batch_size();
+    const auto batch_size = cast_set<size_t>(_runtime_state->batch_size());
     const auto max_block_bytes = _runtime_state->preferred_block_size_bytes();
     *rows = 0;
     *eof = false;
+    _logical_rows = 0;
     _missing_fields.clear();
 
-    while (file_block->rows() < batch_size && !_reader_eof &&
-           file_block->bytes() < max_block_bytes) {
+    while (_logical_rows < batch_size && !_reader_eof && file_block->bytes() < 
max_block_bytes) {
         if (_read_json_by_line && _skip_first_line) {
             RETURN_IF_ERROR(_line_reader->skip_split_prefix(
                     _reader_range.start_offset, _line_delimiter, &_reader_eof, 
_io_ctx.get()));
@@ -400,7 +400,7 @@ Status JsonReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
             continue;
         }
 
-        const size_t original_rows = file_block->rows();
+        const size_t original_rows = _logical_rows;
         size_t size = 0;
         bool is_empty_row = false;
         Status st = Status::OK();
@@ -429,12 +429,13 @@ Status JsonReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
         }
         // An ignored or empty JSON object can produce no row. Avoid spinning 
forever on a document
         // that was consumed but produced no materialized value.
-        if (!is_empty_row && file_block->rows() == original_rows) {
+        if (!is_empty_row && _logical_rows == original_rows) {
             break;
         }
     }
 
-    *rows = file_block->rows();
+    *rows = _logical_rows;
+    DCHECK(file_block->columns() == 0 || file_block->rows() == *rows);
     for (auto& [_, mask] : _missing_fields) {
         mask.resize(*rows, 0);
     }
@@ -810,7 +811,7 @@ Status JsonReader::_append_simple_json_rows(Block* block, 
bool* is_empty_row, bo
         _array_iter = _array.begin();
         while (_array_iter != _array.end()) {
             simdjson::ondemand::object object_value = 
(*_array_iter).get_object();
-            const size_t row_start = block->rows();
+            const size_t row_start = _logical_rows;
             const auto status = _set_column_values_from_object(&object_value, 
block, &valid);
             if (!status.ok()) {
                 if (!_request->reject_invalid_rows) {
@@ -841,12 +842,12 @@ Status 
JsonReader::_append_flat_array_jsonpath_rows(Block* block, bool* is_empty
     DORIS_CHECK(block != nullptr);
     DORIS_CHECK(is_empty_row != nullptr);
     DORIS_CHECK(eof != nullptr);
-    const size_t original_rows = block->rows();
+    const size_t original_rows = _logical_rows;
     bool valid = true;
     _array = _json_value.get_array();
     _array_iter = _array.begin();
     while (_array_iter != _array.end()) {
-        const size_t row_start = block->rows();
+        const size_t row_start = _logical_rows;
         simdjson::ondemand::object object_value = (*_array_iter).get_object();
         if (!_parsed_from_json_root && !_parsed_json_root.empty()) {
             // Reject roots per element for loads, preserving earlier siblings 
and their presence
@@ -885,7 +886,7 @@ Status JsonReader::_append_flat_array_jsonpath_rows(Block* 
block, bool* is_empty
         }
         ++_array_iter;
     }
-    *is_empty_row = block->rows() == original_rows;
+    *is_empty_row = _logical_rows == original_rows;
     return Status::OK();
 }
 
@@ -909,7 +910,7 @@ Status 
JsonReader::_set_column_values_from_object(simdjson::ondemand::object* ob
     DORIS_CHECK(block != nullptr);
     DORIS_CHECK(valid != nullptr);
     std::vector<bool> seen_columns(block->columns(), false);
-    const size_t cur_row_count = block->rows();
+    const size_t cur_row_count = _logical_rows;
     bool has_valid_value = false;
     size_t key_index = 0;
 
@@ -960,21 +961,46 @@ Status 
JsonReader::_set_column_values_from_object(simdjson::ondemand::object* ob
         }
     }
     *valid = true;
+    ++_logical_rows;
     if (!has_valid_value) {
         return Status::OK();
     }
     return Status::OK();
 }
 
+Status JsonReader::_has_matching_jsonpath(simdjson::ondemand::object* 
object_value,
+                                          bool* matches) const {
+    *matches = false;
+    for (const auto& path : _parsed_jsonpaths) {
+        // A root path selects the current document without a field lookup.
+        if (JsonFunctions::is_root_path(path)) {
+            *matches = true;
+            continue;
+        }
+        simdjson::ondemand::value value;
+        const auto status = JsonFunctions::extract_from_object(*object_value, 
path, &value);
+        if (!status.ok() && !status.is<ErrorCode::NOT_FOUND>()) {
+            return status;
+        }
+        *matches = *matches || status.ok();
+    }
+    return Status::OK();
+}
+
 Status JsonReader::_write_columns_by_jsonpath(simdjson::ondemand::object* 
object_value,
                                               Block* block, bool* valid) {
     DORIS_CHECK(object_value != nullptr);
     DORIS_CHECK(block != nullptr);
     DORIS_CHECK(valid != nullptr);
     bool has_valid_value = false;
-    const size_t cur_row_count = block->rows();
+    const size_t cur_row_count = _logical_rows;
     _cached_string_values.clear();
 
+    // Even without projected fields, jsonpaths still decides whether the 
object contributes a row.
+    if (_requested_columns.empty()) {
+        RETURN_IF_ERROR(_has_matching_jsonpath(object_value, 
&has_valid_value));
+    }
+
     for (size_t i = 0; i < _requested_columns.size(); ++i) {
         const auto& requested = _requested_columns[i];
         auto* column_ptr = 
block->get_by_position(i).column->assert_mutable().get();
@@ -1031,6 +1057,7 @@ Status 
JsonReader::_write_columns_by_jsonpath(simdjson::ondemand::object* object
         return Status::OK();
     }
     *valid = true;
+    ++_logical_rows;
     return Status::OK();
 }
 
@@ -1233,6 +1260,7 @@ Status JsonReader::_append_null_for_malformed_json(Block* 
block) {
         assert_cast<ColumnNullable*>(column.get())->insert_default();
         column_with_type.column = std::move(column);
     }
+    ++_logical_rows;
     return Status::OK();
 }
 
@@ -1244,6 +1272,7 @@ Status JsonReader::_handle_json_error(const Status& 
status, Block* block, size_t
     // block to the row count before this document before either surfacing the 
error or appending
     // the ignore-malformed null row.
     _truncate_block_to_rows(block, original_rows);
+    _logical_rows = original_rows;
     for (auto& [_, mask] : _missing_fields) {
         mask.resize(original_rows, 0);
     }
diff --git a/be/src/format_v2/json/json_reader.h 
b/be/src/format_v2/json/json_reader.h
index ba2a0d99e2f..b1c413da0e7 100644
--- a/be/src/format_v2/json/json_reader.h
+++ b/be/src/format_v2/json/json_reader.h
@@ -148,6 +148,7 @@ private:
     Status _append_nested_jsonpath_row(Block* block, bool* is_empty_row, bool* 
eof);
     Status _set_column_values_from_object(simdjson::ondemand::object* 
object_value, Block* block,
                                           bool* valid);
+    Status _has_matching_jsonpath(simdjson::ondemand::object* object_value, 
bool* matches) const;
     Status _write_columns_by_jsonpath(simdjson::ondemand::object* 
object_value, Block* block,
                                       bool* valid);
     template <bool use_string_cache>
@@ -191,6 +192,8 @@ private:
     std::unique_ptr<Decompressor> _decompressor;
     std::unique_ptr<NewPlainTextLineReader> _line_reader;
     int64_t _current_offset = 0;
+    // Partition-only scans have no file columns, so Block::rows() cannot 
count their records.
+    size_t _logical_rows = 0;
     bool _reader_eof = false;
     bool _skip_first_line = false;
     bool _single_document_read = false;
diff --git a/be/src/format_v2/wal/wal_reader.cpp 
b/be/src/format_v2/wal/wal_reader.cpp
index 55e88796c98..5f57a422ff8 100644
--- a/be/src/format_v2/wal/wal_reader.cpp
+++ b/be/src/format_v2/wal/wal_reader.cpp
@@ -195,8 +195,11 @@ Status WalReader::get_block(Block* file_block, size_t* 
rows, bool* eof) {
         return Status::Corruption("WAL block has {} columns but header 
declares {}",
                                   source_block.columns(), _column_ids.size());
     }
+    // Empty projections still contribute every WAL row. Capture the count 
before materialization
+    // moves source columns into the output block and can leave the source 
with no owned columns.
+    const size_t source_rows = source_block.rows();
     RETURN_IF_ERROR(_materialize_requested_columns(&source_block, file_block));
-    *rows = file_block->rows();
+    *rows = source_rows;
     _record_scan_rows(cast_set<int64_t>(*rows));
     RETURN_IF_ERROR(
             apply_materialized_reader_filters(_request.get(), _io_ctx.get(), 
file_block, rows));
diff --git a/be/test/format_v2/json/json_reader_test.cpp 
b/be/test/format_v2/json/json_reader_test.cpp
index b9711973de2..bf82fbcf01d 100644
--- a/be/test/format_v2/json/json_reader_test.cpp
+++ b/be/test/format_v2/json/json_reader_test.cpp
@@ -46,6 +46,7 @@
 #include "exprs/vexpr.h"
 #include "exprs/vexpr_context.h"
 #include "format_v2/column_data.h"
+#include "format_v2/table_reader.h"
 #include "io/io_common.h"
 #include "runtime/descriptors.h"
 #include "runtime/exec_env.h"
@@ -1031,4 +1032,165 @@ TEST(JsonReaderTest, 
AppliesDeleteAndNormalConjunctsWithPredicateFilterAccountin
     EXPECT_EQ(io_ctx->predicate_filtered_rows, 1);
 }
 
+// Partition-only scans have no file-local output columns, but still need one 
logical row per
+// accepted JSON record so TableReader can materialize the partition constants.
+TEST(JsonReaderTest, EmptyFileProjectionPreservesRows) {
+    ObjectPool pool;
+    const auto slots = build_slots(&pool);
+    for (const auto& source_slots : {slots, std::vector<SlotDescriptor*> {}}) {
+        auto result = read_once("empty_projection.jsonl", 
"{\"id\":1}\n\n{}\n{\"id\":3}\n",
+                                json_scan_params(), source_slots, {}, true, 
true);
+        ASSERT_TRUE(result.status.ok()) << result.status;
+        EXPECT_EQ(result.block.columns(), 0);
+        EXPECT_EQ(result.rows, 3);
+        EXPECT_FALSE(result.eof);
+        EXPECT_TRUE(result.second_status.ok());
+        EXPECT_EQ(result.second_rows, 0);
+        EXPECT_TRUE(result.second_eof);
+    }
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionPreservesOuterArrayRows) {
+    auto result = read_once("empty_projection_array.json", 
R"([{"id":1},{},{"id":3}])",
+                            json_scan_params(false, true), {}, {}, true, true);
+    ASSERT_TRUE(result.status.ok()) << result.status;
+    EXPECT_EQ(result.rows, 3);
+    EXPECT_TRUE(result.second_status.ok());
+    EXPECT_TRUE(result.second_eof);
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionPreservesJsonRootRows) {
+    auto result = read_once("empty_projection_root.jsonl",
+                            
"{\"payload\":{\"id\":1}}\n{\"payload\":{\"id\":2}}\n",
+                            json_scan_params(true, false, "", "$.payload"), 
{}, {}, true, true);
+    ASSERT_TRUE(result.status.ok()) << result.status;
+    EXPECT_EQ(result.rows, 2);
+    EXPECT_TRUE(result.second_status.ok());
+    EXPECT_TRUE(result.second_eof);
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionHonorsJsonPaths) {
+    ObjectPool pool;
+    const auto slots = build_slots(&pool);
+    auto result = read_once("empty_projection_jsonpaths.jsonl",
+                            
"{\"payload\":{\"id\":1}}\n{\"other\":2}\n{\"payload\":{\"id\":3}}\n",
+                            json_scan_params(true, false, 
R"(["$.payload.id","$.payload.user"])"),
+                            slots, {}, true, true);
+    ASSERT_TRUE(result.status.ok()) << result.status;
+    EXPECT_EQ(result.rows, 2);
+    EXPECT_TRUE(result.second_status.ok());
+    EXPECT_TRUE(result.second_eof);
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionPreservesRootJsonPathRows) {
+    auto result = read_once("empty_projection_root_path.jsonl", 
"{\"id\":1}\n{}\n",
+                            json_scan_params(true, false, R"(["$."])"), {}, 
{}, true, true);
+    ASSERT_TRUE(result.status.ok()) << result.status;
+    EXPECT_EQ(result.rows, 2);
+    EXPECT_TRUE(result.second_eof);
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionPreservesRootedArrayJsonPathRows) {
+    auto result = read_once("empty_projection_rooted_array.json",
+                            
R"({"rows":[{"payload":{"id":1}},{"other":2},{"payload":{"id":3}}]})",
+                            json_scan_params(false, true, 
R"(["$.payload.id"])", "$.rows"), {}, {},
+                            true, true);
+    ASSERT_TRUE(result.status.ok()) << result.status;
+    EXPECT_EQ(result.rows, 2);
+    EXPECT_TRUE(result.second_status.ok());
+    EXPECT_TRUE(result.second_eof);
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionPreservesMalformedNullRows) {
+    auto result = read_once("empty_projection_malformed.jsonl", 
"not-json\n{\"id\":1}\n",
+                            json_scan_params(true, false, "", "", true), {}, 
{}, true, true);
+    ASSERT_TRUE(result.status.ok()) << result.status;
+    EXPECT_EQ(result.rows, 2);
+    EXPECT_TRUE(result.second_status.ok());
+    EXPECT_TRUE(result.second_eof);
+
+    auto strict = read_once("empty_projection_strict.jsonl", "not-json\n", 
json_scan_params(), {},
+                            {}, false, true);
+    EXPECT_FALSE(strict.status.ok());
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionPreservesEmptyInput) {
+    for (const std::string content : {"", "\n", "[]\n"}) {
+        auto result = read_once("empty_projection_empty.jsonl", content,
+                                json_scan_params(true, true), {}, {}, false, 
true);
+        ASSERT_TRUE(result.status.ok()) << result.status;
+        EXPECT_EQ(result.rows, 0);
+        EXPECT_TRUE(result.eof);
+    }
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionHonorsBatchSize) {
+    const auto path = write_json_file("empty_projection_batches.jsonl",
+                                      "{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n"
+                                      "{\"id\":4}\n{\"id\":5}\n");
+    auto properties = std::make_shared<io::FileSystemProperties>();
+    properties->system_type = TFileType::FILE_LOCAL;
+    auto desc = file_description(path.string());
+    auto params = json_scan_params();
+    RuntimeProfile profile("json_empty_projection_batches");
+    MockRuntimeState state;
+    state._batch_size = 2;
+    JsonReader reader(properties, desc, std::make_shared<io::IOContext>(), 
&profile, &params,
+                      file_range(path), {});
+    ASSERT_TRUE(reader.init(&state).ok());
+    ASSERT_TRUE(reader.open(std::make_shared<FileScanRequest>()).ok());
+    for (const size_t expected_rows : {2, 2, 1, 0}) {
+        Block block;
+        size_t rows = 0;
+        bool eof = false;
+        ASSERT_TRUE(reader.get_block(&block, &rows, &eof).ok());
+        EXPECT_EQ(block.columns(), 0);
+        EXPECT_EQ(rows, expected_rows);
+        EXPECT_EQ(eof, expected_rows == 0);
+    }
+}
+
+TEST(JsonReaderTest, EmptyFileProjectionMaterializesPartitionRows) {
+    const auto path =
+            write_json_file("partition_only.jsonl", 
"{\"id\":1}\n{\"id\":2}\n{\"id\":3}\n");
+    auto params = json_scan_params();
+    const std::vector<SlotDescriptor*> file_slots;
+    ColumnDefinition partition;
+    partition.identifier = Field::create_field<TYPE_STRING>("dt");
+    partition.local_id = 0;
+    partition.name = "dt";
+    partition.type = std::make_shared<DataTypeString>();
+    partition.is_partition_key = true;
+    RuntimeProfile profile("json_partition_only");
+    MockRuntimeState state;
+    state._batch_size = 2;
+    TableReader reader;
+    ASSERT_TRUE(reader.init({.projected_columns = {partition},
+                             .conjuncts = {},
+                             .format = FileFormat::JSON,
+                             .scan_params = &params,
+                             .io_ctx = std::make_shared<io::IOContext>(),
+                             .runtime_state = &state,
+                             .scanner_profile = &profile,
+                             .file_slot_descs = &file_slots})
+                        .ok());
+    SplitReadOptions split;
+    split.current_range = file_range(path);
+    split.current_split_format = FileFormat::JSON;
+    split.partition_values.emplace("dt", 
Field::create_field<TYPE_STRING>("p1"));
+    ASSERT_TRUE(reader.prepare_split(split).ok());
+    bool eos = false;
+    for (const size_t expected_rows : {2, 1, 0}) {
+        Block block({{partition.type->create_column(), partition.type, 
partition.name}});
+        ASSERT_TRUE(reader.get_block(&block, &eos).ok());
+        ASSERT_EQ(block.rows(), expected_rows);
+        EXPECT_EQ(eos, expected_rows == 0);
+        const auto column = 
block.get_by_position(0).column->convert_to_full_column_if_const();
+        for (size_t row = 0; row < expected_rows; ++row) {
+            EXPECT_EQ(column->get_data_at(row).to_string(), "p1");
+        }
+    }
+    EXPECT_TRUE(reader.close().ok());
+}
+
 } // namespace doris::format::json
diff --git a/be/test/format_v2/wal/wal_reader_test.cpp 
b/be/test/format_v2/wal/wal_reader_test.cpp
index 77c14afb12f..65e37bd5f89 100644
--- a/be/test/format_v2/wal/wal_reader_test.cpp
+++ b/be/test/format_v2/wal/wal_reader_test.cpp
@@ -22,6 +22,8 @@
 #include <chrono>
 #include <filesystem>
 #include <memory>
+#include <tuple>
+#include <vector>
 
 #include "agent/be_exec_version_manager.h"
 #include "core/block/block.h"
@@ -60,8 +62,126 @@ PBlock serialize_block(const Block& block) {
     return pblock;
 }
 
+void expect_wal_column(const IColumn& column, const std::string& name, size_t 
expected_rows,
+                       size_t offset) {
+    ASSERT_EQ(column.size(), expected_rows);
+    for (size_t row = 0; row < expected_rows; ++row) {
+        EXPECT_EQ(column.get_data_at(row).to_string(), name + 
std::to_string(offset + row));
+    }
+}
+
+void expect_wal_batch(WalReader* reader, Block* output, size_t 
expected_columns,
+                      size_t expected_rows, size_t offset) {
+    output->clear_column_data();
+    size_t rows = 0;
+    bool eof = true;
+    ASSERT_TRUE(reader->get_block(output, &rows, &eof).ok());
+    // Projection can remove every output column while the WAL block still 
contributes rows.
+    EXPECT_EQ(rows, expected_rows);
+    EXPECT_FALSE(eof);
+    ASSERT_EQ(output->columns(), expected_columns);
+    for (const auto& column : output->get_columns_with_type_and_name()) {
+        ASSERT_NO_FATAL_FAILURE(
+                expect_wal_column(*column.column, column.name, expected_rows, 
offset));
+    }
+}
+
+void initialize_wal_reader(WalReader* reader, const std::string& path, bool 
discover_schema) {
+    reader->_wal_reader = std::make_shared<WalFileReader>(path);
+    ASSERT_TRUE(reader->_wal_reader->init().ok());
+    std::string encoded_ids;
+    ASSERT_TRUE(reader->_wal_reader->read_header(reader->_version, 
encoded_ids).ok());
+    ASSERT_TRUE(parse_wal_column_ids(encoded_ids, &reader->_column_ids).ok());
+    if (discover_schema) {
+        std::vector<ColumnDefinition> schema;
+        ASSERT_TRUE(reader->get_schema(&schema).ok());
+        ASSERT_EQ(schema.size(), 2);
+    }
+}
+
+void expect_wal_eof(WalReader* reader, Block* output) {
+    output->clear_column_data();
+    size_t rows = 99;
+    bool eof = false;
+    ASSERT_TRUE(reader->get_block(output, &rows, &eof).ok());
+    EXPECT_EQ(rows, 0);
+    EXPECT_TRUE(eof);
+    EXPECT_EQ(output->rows(), 0);
+}
+
+class WalReaderV2ProjectionTest
+        : public testing::TestWithParam<std::tuple<std::vector<int>, bool>> {
+protected:
+    void SetUp() override {
+        _wal_path = temporary_wal_path();
+        const auto type = std::make_shared<DataTypeString>();
+        WalWriter writer(_wal_path);
+        ASSERT_TRUE(writer.init(io::global_local_filesystem()).ok());
+        ASSERT_TRUE(writer.append_header("17,4").ok());
+        size_t offset = 0;
+        for (const size_t batch_rows : {3, 2}) {
+            Block source;
+            for (const auto& name : {"left_value", "right_value"}) {
+                auto column = type->create_column();
+                for (size_t row = 0; row < batch_rows; ++row) {
+                    const auto value = std::string(name) + 
std::to_string(offset + row);
+                    column->insert_data(value.data(), value.size());
+                }
+                source.insert({std::move(column), type, name});
+            }
+            auto pblock = serialize_block(source);
+            ASSERT_TRUE(writer.append_blocks({&pblock}).ok());
+            offset += batch_rows;
+        }
+        ASSERT_TRUE(writer.finalize().ok());
+    }
+
+    void TearDown() override {
+        std::filesystem::remove_all(
+                
std::filesystem::path(_wal_path).parent_path().parent_path().parent_path());
+    }
+
+    std::string _wal_path;
+};
+
 } // namespace
 
+TEST_P(WalReaderV2ProjectionTest, PreservesRowsAcrossBlocksAndEof) {
+    const auto& [projected_columns, discover_schema] = GetParam();
+    std::shared_ptr<io::FileSystemProperties> properties;
+    std::unique_ptr<io::FileDescription> description;
+    WalReader reader(properties, description, nullptr, nullptr, {});
+    ASSERT_NO_FATAL_FAILURE(initialize_wal_reader(&reader, _wal_path, 
discover_schema));
+
+    auto request = std::make_shared<FileScanRequest>();
+    Block output;
+    const auto type = std::make_shared<DataTypeString>();
+    const std::vector<std::string> names {"left_value", "right_value"};
+    for (size_t position = 0; position < projected_columns.size(); ++position) 
{
+        const auto source_position = projected_columns[position];
+        request->local_positions.emplace(LocalColumnId(source_position), 
LocalIndex(position));
+        output.insert({type->create_column(), type, names[source_position]});
+    }
+    ASSERT_TRUE(reader.open(std::move(request)).ok());
+
+    size_t offset = 0;
+    for (const size_t expected_rows : {3, 2}) {
+        ASSERT_NO_FATAL_FAILURE(expect_wal_batch(&reader, &output, 
projected_columns.size(),
+                                                 expected_rows, offset));
+        offset += expected_rows;
+    }
+    for (int attempt = 0; attempt < 2; ++attempt) {
+        ASSERT_NO_FATAL_FAILURE(expect_wal_eof(&reader, &output));
+    }
+    ASSERT_TRUE(reader.close().ok());
+}
+
+INSTANTIATE_TEST_SUITE_P(WriterBacked, WalReaderV2ProjectionTest,
+                         testing::Combine(testing::Values(std::vector<int> {}, 
std::vector<int> {0},
+                                                          std::vector<int> {1},
+                                                          std::vector<int> {1, 
0}),
+                                          testing::Bool()));
+
 TEST(WalReaderV2Test, ParseColumnIdsPreservesHeaderOrder) {
     std::vector<int32_t> column_ids;
     ASSERT_TRUE(parse_wal_column_ids("17,4,99", &column_ids).ok());
diff --git 
a/regression-test/data/insert_p0/group_commit/test_group_commit_wal_row_count.out
 
b/regression-test/data/insert_p0/group_commit/test_group_commit_wal_row_count.out
new file mode 100644
index 00000000000..0ae4de99333
--- /dev/null
+++ 
b/regression-test/data/insert_p0/group_commit/test_group_commit_wal_row_count.out
@@ -0,0 +1,17 @@
+-- This file is automatically generated. You should know what you did if you 
want to edit this
+-- !before_replay --
+0
+
+-- !counts --
+5      4       15      120
+
+-- !values --
+1      10
+2      20
+3      \N
+4      40
+5      50
+
+-- !filtered_count --
+3
+
diff --git 
a/regression-test/suites/external_table_p0/hive/test_hive_json_partition_only.groovy
 
b/regression-test/suites/external_table_p0/hive/test_hive_json_partition_only.groovy
new file mode 100644
index 00000000000..69ae572d563
--- /dev/null
+++ 
b/regression-test/suites/external_table_p0/hive/test_hive_json_partition_only.groovy
@@ -0,0 +1,85 @@
+// 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_hive_json_partition_only", "p0,external,hive") {
+    String enabled = context.config.otherConfigs.get("enableHiveTest")
+    if (enabled == null || !enabled.equalsIgnoreCase("true")) {
+        logger.info("disable Hive test")
+        return
+    }
+
+    String externalEnvIp = context.config.otherConfigs.get("externalEnvIp")
+    String hmsPort = context.config.otherConfigs.get("hive3HmsPort")
+    String catalog = "test_hive_json_partition_only"
+    def previousSettings = sql """SELECT 
@@enable_count_push_down_for_external_table,
+                                       @@enable_sql_cache"""
+    sql """DROP CATALOG IF EXISTS ${catalog}"""
+    try {
+        sql """CREATE CATALOG ${catalog} PROPERTIES (
+            "type" = "hms",
+            "hive.metastore.uris" = "thrift://${externalEnvIp}:${hmsPort}"
+        )"""
+        sql """USE `${catalog}`.`default`"""
+        sql "SET enable_sql_cache=false"
+
+        // Reading a real JSON field provides the row-count oracle for the 
same partition files.
+        def baseline = sql "SELECT id, dt FROM json_all_complex_types"
+        assertTrue(!baseline.isEmpty())
+        def partitions = baseline.groupBy { row -> row[1] }
+
+        for (boolean pushdown : [false, true]) {
+            sql "SET enable_count_push_down_for_external_table=${pushdown}"
+            partitions.each { partition, rows ->
+                assertNotNull(partition)
+                String value = partition.toString().replace("'", "''")
+                String predicate = "dt = '${value}'"
+                String countQuery = "SELECT COUNT(*) FROM 
json_all_complex_types WHERE ${predicate}"
+                explain {
+                    sql countQuery
+                    contains "pushdown agg=NONE"
+                }
+                def count = sql countQuery
+                assertEquals(rows.size(), (count[0][0] as Number).intValue())
+
+                // Partition values come from the split, so neither query 
needs a JSON file column.
+                def projected = sql "SELECT dt FROM json_all_complex_types 
WHERE ${predicate}"
+                assertEquals(rows.size(), projected.size())
+                assertTrue(projected.every { row -> row[0] == partition })
+                def partitionCount = sql "SELECT COUNT(dt) FROM 
json_all_complex_types WHERE ${predicate}"
+                assertEquals(rows.size(), (partitionCount[0][0] as 
Number).intValue())
+
+                def filtered = sql """SELECT COUNT(*) FROM 
json_all_complex_types
+                                      WHERE ${predicate} AND id IS NOT NULL"""
+                assertEquals(rows.count { row -> row[0] != null },
+                             (filtered[0][0] as Number).intValue())
+            }
+
+            def grouped = sql "SELECT dt, COUNT(*) FROM json_all_complex_types 
GROUP BY dt"
+            assertEquals(partitions.size(), grouped.size())
+            grouped.each { row ->
+                assertEquals(partitions[row[0]].size(), (row[1] as 
Number).intValue())
+            }
+            def empty = sql """SELECT COUNT(*) FROM json_all_complex_types
+                               WHERE dt = '__missing_json_partition__'"""
+            assertEquals(0, (empty[0][0] as Number).intValue())
+        }
+    } finally {
+        sql "SET 
enable_count_push_down_for_external_table=${previousSettings[0][0]}"
+        sql "SET enable_sql_cache=${previousSettings[0][1]}"
+        sql """DROP CATALOG IF EXISTS ${catalog}"""
+    }
+}
diff --git 
a/regression-test/suites/insert_p0/group_commit/test_group_commit_wal_row_count.groovy
 
b/regression-test/suites/insert_p0/group_commit/test_group_commit_wal_row_count.groovy
new file mode 100644
index 00000000000..ed1a449724a
--- /dev/null
+++ 
b/regression-test/suites/insert_p0/group_commit/test_group_commit_wal_row_count.groovy
@@ -0,0 +1,85 @@
+// 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.
+
+import org.awaitility.Awaitility
+import static java.util.concurrent.TimeUnit.SECONDS
+
+suite("test_group_commit_wal_row_count", "nonConcurrent") {
+    sql "DROP TABLE IF EXISTS test_group_commit_wal_row_count"
+    sql """
+        CREATE TABLE test_group_commit_wal_row_count (
+            k INT NOT NULL,
+            v INT NULL
+        ) ENGINE=OLAP
+        DUPLICATE KEY(k)
+        DISTRIBUTED BY HASH(k) BUCKETS 1
+        PROPERTIES (
+            "replication_num" = "1",
+            "group_commit_interval_ms" = "2000"
+        )
+    """
+
+    String loadError = "LoadBlockQueue._finish_group_commit_load.load_error"
+    String replayStop = "WalTable.replay_wals.stop"
+    try {
+        GetDebugPoint().enableDebugPointForAllBEs(loadError)
+        GetDebugPoint().enableDebugPointForAllBEs(replayStop)
+
+        // Abort each original transaction before the next load so both WALs 
must be replayed.
+        for (String data : ["1,10\n2,20\n3,\\N\n", "4,40\n5,50\n"]) {
+            long txnId = 0
+            streamLoad {
+                table "test_group_commit_wal_row_count"
+                set "column_separator", ","
+                set "group_commit", "async_mode"
+                unset "label"
+                inputStream new ByteArrayInputStream(data.getBytes("UTF-8"))
+                time 10000
+                check { result, exception, startTime, endTime ->
+                    assertNull(exception)
+                    def response = parseJson(result)
+                    assertEquals("Success", response.Status)
+                    assertEquals(data.readLines().size(), 
response.NumberTotalRows as int)
+                    txnId = response.TxnId as long
+                    assertTrue(txnId > 0)
+                }
+            }
+            Awaitility.await().atMost(30, SECONDS).pollInterval(1, 
SECONDS).until {
+                def txn = sql_return_maparray "SHOW TRANSACTION WHERE ID = 
${txnId}"
+                return txn.size() == 1 && txn[0].TransactionStatus == "ABORTED"
+            }
+        }
+        qt_before_replay "SELECT COUNT(*) FROM test_group_commit_wal_row_count"
+
+        // Keep the original-load failure enabled: rows can become visible 
only through WAL replay.
+        GetDebugPoint().disableDebugPointForAllBEs(replayStop)
+        Awaitility.await().atMost(90, SECONDS).pollInterval(1, SECONDS).until {
+            def rows = sql "SELECT COUNT(*) FROM 
test_group_commit_wal_row_count"
+            return rows[0][0] == 5
+        }
+        qt_counts """SELECT COUNT(*), COUNT(v), SUM(k), SUM(v)
+                     FROM test_group_commit_wal_row_count"""
+        qt_values "SELECT k, v FROM test_group_commit_wal_row_count ORDER BY k"
+        qt_filtered_count "SELECT COUNT(*) FROM 
test_group_commit_wal_row_count WHERE v >= 20"
+    } finally {
+        try {
+            GetDebugPoint().disableDebugPointForAllBEs(replayStop)
+        } finally {
+            GetDebugPoint().disableDebugPointForAllBEs(loadError)
+        }
+    }
+}


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

Reply via email to